@abinnovision/payloadcms-mcpx 2.0.0-beta.7 → 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.
package/README.md CHANGED
@@ -86,30 +86,29 @@ Claude Desktop and the MCP Inspector are covered in
86
86
 
87
87
  ## Options
88
88
 
89
- | Option | Default | Description |
90
- | ----------------------------- | ----------------------------------------- | ------------------------------------------------------------------- |
91
- | `collections` | required | Collections to expose. `true` means `{}`, everything it supports. |
92
- | `collections.<slug>.read` | `true` | Expose the read tools for this collection. |
93
- | `collections.<slug>.write` | `true` | Expose the write tools. Without drafts, a write goes live. |
94
- | `collections.<slug>.publish` | `true` with drafts | Expose `publishDocument`. Needs `versions.drafts` and `write`. |
95
- | `collections.<slug>.versions` | `false` | Expose version history. Needs Payload `versions` on the entity. |
96
- | `globals` | `{}` | Globals to expose, with the same four options. |
97
- | `userCollection` | `config.admin.user`, then `users` | Auth collection whose users the keys act as. |
98
- | `apiKeys.slug` | `mcpx-api-keys` | Slug of the key collection. |
99
- | `apiKeys.setupGuide` | `true` | Add the "Connect a client" tab to saved keys. |
100
- | `apiKeys.overrideCollection` | none | Function that receives the key collection and returns it. |
101
- | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
102
- | `limits.maxLimit` | `25` | Highest `limit` a client may pass to a list tool. |
103
- | `limits.maxDepth` | `1` | Highest `depth` a client may pass to a read tool. |
104
- | `tools` | `[]` | Custom tools, see [`docs/custom-tools.md`](./docs/custom-tools.md). |
105
- | `auth.resolve` | none | Replace or wrap the key lookup. |
106
- | `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients. |
89
+ | Option | Default | Description |
90
+ | ---------------------------- | ----------------------------------------- | ------------------------------------------------------------------- |
91
+ | `collections` | required | Collections to expose. `true` means `{}`, everything it supports. |
92
+ | `collections.<slug>.read` | `true` | Expose the read tools for this collection. |
93
+ | `collections.<slug>.write` | `true` | Expose the write tools. Without drafts, a write goes live. |
94
+ | `collections.<slug>.publish` | `true` with drafts | Expose `publishDocument`. Needs `versions.drafts` and `write`. |
95
+ | `globals` | `{}` | Globals to expose, with the same three options. |
96
+ | `userCollection` | `config.admin.user`, then `users` | Auth collection whose users the keys act as. |
97
+ | `apiKeys.slug` | `mcpx-api-keys` | Slug of the key collection. |
98
+ | `apiKeys.setupGuide` | `true` | Add the "Connect a client" tab to saved keys. |
99
+ | `apiKeys.overrideCollection` | none | Function that receives the key collection and returns it. |
100
+ | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
101
+ | `limits.maxLimit` | `25` | Highest `limit` a client may pass to a list tool. |
102
+ | `limits.maxDepth` | `1` | Highest `depth` a client may pass to a read tool. |
103
+ | `tools` | `[]` | Custom tools, see [`docs/custom-tools.md`](./docs/custom-tools.md). |
104
+ | `auth.resolve` | none | Replace or wrap the key lookup. |
105
+ | `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients. |
107
106
 
108
107
  The config only takes capabilities away: `true` exposes everything the entity supports, and a key's
109
108
  checkboxes decide what each key may do. `publish: false` keeps MCP writes as drafts. On an entity
110
- without drafts, a write changes the live document. `versions` defaults to `false`: version history
111
- has no checkbox of its own and follows the key's read, so the config opts in. Read
112
- [version history](./docs/security.md#version-history) before turning it on.
109
+ without drafts, a write changes the live document. Version history has no checkbox of its own: it
110
+ follows the key's read wherever the entity keeps Payload `versions`. Read
111
+ [version history](./docs/security.md#version-history) first.
113
112
  Mistakes in the options fail at startup. [`docs/integration.md`](./docs/integration.md) lists
114
113
  every check.
115
114
 
@@ -121,7 +120,7 @@ every check.
121
120
  | `describeSchema` | Describes the fields at one schema path, with the paths below it. | `read` |
122
121
  | `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read` |
123
122
  | `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read` |
124
- | `findVersions` | Lists the version history of a document or global, without bodies. | `read`, `versions` |
123
+ | `findVersions` | Lists the version history of a document or global, without bodies. | `read` |
125
124
  | `patchDocument` | Applies JSON Patch operations to the current draft. | `write` |
126
125
  | `createDocument` | Creates a draft from a seed. Not for upload collections. | `write` |
127
126
  | `validateDocument` | Lists what blocks publishing, without saving. | `write` |
@@ -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.`);
@@ -21,8 +23,7 @@ const assertExposable = (collection, apiKeysSlug) => {
21
23
  const OPTION_NAMES = [
22
24
  "read",
23
25
  "write",
24
- "publish",
25
- "versions"
26
+ "publish"
26
27
  ];
27
28
  const REMOVED_WRITE_MODES = {
28
29
  draft: "Use { publish: false } instead.",
@@ -47,18 +48,15 @@ const normalizeCapabilities = (kind, config, raw) => {
47
48
  const read = flag("read") ?? true;
48
49
  const write = flag("write") ?? true;
49
50
  const publish = flag("publish");
50
- const versions = flag("versions") ?? false;
51
51
  const hasDrafts = hasDraftsEnabled(config);
52
52
  if (publish === false && write && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so every write goes live. Set write: false or remove publish: false.`);
53
53
  if (publish === true && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so there is nothing to publish. Enable versions.drafts or remove publish: true.`);
54
54
  if (publish === true && !write) fail(`${kind} "${slug}" has publish: true but write: false. Publishing follows write.`);
55
- if (versions && !config.versions) fail(`${kind} "${slug}" keeps no versions. Enable versions or remove versions: true.`);
56
- if (versions && !read) fail(`${kind} "${slug}" has versions: true but read: false. Version history follows read.`);
57
55
  return {
58
56
  read,
59
57
  write: write ? publish === false ? "draft" : "live" : false,
60
58
  hasDrafts,
61
- hasVersions: versions
59
+ hasVersions: read && Boolean(config.versions)
62
60
  };
63
61
  };
64
62
  const assertGlobalExposable = (global) => {
@@ -131,7 +129,9 @@ const normalizeLimits = (limits) => {
131
129
  maxDepth
132
130
  };
133
131
  };
134
- /** 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) => {
135
135
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
136
136
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
137
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 };
@@ -16,8 +16,8 @@ const statusShape = (scope) => {
16
16
  /**
17
17
  * Bodies are left out so a long history stays small; `getDocument` reads one
18
18
  * when it is needed. The document's `read` access is checked first, then
19
- * Payload's `readVersions`. Only entities with `versions: true` in the plugin
20
- * options are reachable.
19
+ * Payload's `readVersions`. Only entities that keep Payload versions are
20
+ * reachable.
21
21
  */ const findVersions = defineMcpxTool({
22
22
  name: "findVersions",
23
23
  description: DESCRIPTION,
@@ -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
@@ -43,46 +45,54 @@ export interface McpxCollectionOptions {
43
45
  * `false` keeps writes as drafts and is refused where there are none.
44
46
  */
45
47
  publish?: boolean;
46
- /**
47
- * Expose `findVersions` and the `versionId` and `diffFrom` arguments of
48
- * `getDocument` to keys that may read the entity. Default `false`: version
49
- * history has no checkbox of its own and follows the key's read, so the
50
- * config opts in. `true` needs Payload `versions` and `read`.
51
- */
52
- versions?: boolean;
53
48
  }
54
- /** 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
+ */
55
53
  export type McpxGlobalOptions = McpxCollectionOptions;
56
54
  export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
57
- /** 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
+ */
58
58
  export interface McpxExposedEntity {
59
59
  slug: string;
60
60
  read: boolean;
61
61
  write: McpxWriteMode;
62
62
  hasDrafts: boolean;
63
63
  /**
64
- * Payload keeps a version history, with or without drafts, and the config
65
- * exposes it. Says nothing about drafts; see `hasDrafts`.
64
+ * The entity has Payload `versions`, with or without drafts, and is
65
+ * readable. Says nothing about drafts; see `hasDrafts`.
66
66
  */
67
67
  hasVersions: boolean;
68
- /** 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
+ */
69
71
  isUpload: boolean;
70
- /** Name of the capability group on the key document. */
72
+ /**
73
+ * Name of the capability group on the key document.
74
+ */
71
75
  fieldName: string;
72
76
  }
73
- /** The slugs a key may read, write and publish. */
77
+ /**
78
+ * The slugs a key may read, write and publish.
79
+ */
74
80
  export interface McpxScopeSlugs {
75
81
  readable: string[];
76
82
  writable: string[];
77
83
  publishable: string[];
78
84
  }
79
- /** What a tool knows about the current request. */
85
+ /**
86
+ * What a tool knows about the current request.
87
+ */
80
88
  export interface McpxToolScope {
81
89
  req: PayloadRequest;
82
90
  capabilities: McpxResolvedCapabilities;
83
91
  collections: McpxScopeSlugs;
84
92
  globals: McpxScopeSlugs;
85
- /** `null` when localization is off. */
93
+ /**
94
+ * `null` when localization is off.
95
+ */
86
96
  localization: null | {
87
97
  locales: string[];
88
98
  defaultLocale: string;
@@ -102,9 +112,13 @@ export interface McpxToolScope {
102
112
  * per request, leaving no static shape to infer from.
103
113
  */
104
114
  export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
105
- /** camelCase, unique, not one of the builtin tool names. */
115
+ /**
116
+ * camelCase, unique, not one of the builtin tool names.
117
+ */
106
118
  name: string;
107
- /** 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
+ */
108
122
  description: string | ((scope: McpxToolScope) => string);
109
123
  annotations?: ToolAnnotations;
110
124
  /**
@@ -122,18 +136,26 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
122
136
  handler(ctx: {
123
137
  args: Args;
124
138
  scope: McpxToolScope;
125
- /** Shorthand for `scope.req`. */
139
+ /**
140
+ * Shorthand for `scope.req`.
141
+ */
126
142
  req: PayloadRequest;
127
143
  extra: McpxToolExtra;
128
144
  }): CallToolResult | Promise<CallToolResult>;
129
145
  }
130
- /** 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
+ */
131
149
  export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
132
150
  export interface McpxAuthResult {
133
- /** Must carry `collection`. */
151
+ /**
152
+ * Must carry `collection`.
153
+ */
134
154
  user: TypedUser;
135
155
  apiKeyId: DocumentId;
136
- /** The `capabilities` group as stored on the key document. */
156
+ /**
157
+ * The `capabilities` group as stored on the key document.
158
+ */
137
159
  capabilities: unknown;
138
160
  }
139
161
  /**
@@ -143,31 +165,47 @@ export interface McpxAuthResult {
143
165
  * to `Record<string, unknown>`, which interfaces do not satisfy.
144
166
  */
145
167
  export type McpxPluginOptions = {
146
- /** Allow-list of collections. `true` is shorthand for `{}`. */
168
+ /**
169
+ * Allow-list of collections. `true` is shorthand for `{}`.
170
+ */
147
171
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
148
- /** Allow-list of globals. `true` is shorthand for `{}`. */
172
+ /**
173
+ * Allow-list of globals. `true` is shorthand for `{}`.
174
+ */
149
175
  globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
150
- /** 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
+ */
151
179
  userCollection?: CollectionSlug;
152
180
  apiKeys?: {
153
- /** 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
+ */
154
184
  slug?: string;
155
185
  /**
156
186
  * Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
157
187
  * client config. Default `true`. The snippets contain the key in full.
158
188
  */
159
189
  setupGuide?: boolean;
160
- /** Final override applied to the generated collection. */
190
+ /**
191
+ * Final override applied to the generated collection.
192
+ */
161
193
  overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
162
194
  };
163
195
  endpoint?: {
164
- /** Endpoint path below the API route. Default `/mcpx`. */
196
+ /**
197
+ * Endpoint path below the API route. Default `/mcpx`.
198
+ */
165
199
  path?: string;
166
200
  };
167
201
  limits?: {
168
- /** Upper bound for `findDocuments.limit`. Default 25. */
202
+ /**
203
+ * Upper bound for `findDocuments.limit`. Default 25.
204
+ */
169
205
  maxLimit?: number;
170
- /** Upper bound for `depth` on reads. Default 1. */
206
+ /**
207
+ * Upper bound for `depth` on reads. Default 1.
208
+ */
171
209
  maxDepth?: number;
172
210
  };
173
211
  tools?: McpxAnyTool[];
@@ -187,20 +225,28 @@ export type McpxPluginOptions = {
187
225
  version?: string;
188
226
  };
189
227
  };
190
- /** 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
+ */
191
231
  export interface McpxCollectionCapabilities {
192
232
  read: boolean;
193
233
  write: boolean;
194
- /** 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
+ */
195
237
  publish: boolean;
196
238
  }
197
- /** 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
+ */
198
242
  export interface McpxResolvedCapabilities {
199
243
  collections: Record<string, McpxCollectionCapabilities>;
200
244
  globals: Record<string, McpxCollectionCapabilities>;
201
245
  tools: Record<string, boolean>;
202
246
  }
203
- /** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
247
+ /**
248
+ * Stamped on `req.context.mcpx`; see {@link isMcpxRequest}.
249
+ */
204
250
  export interface McpxRequestContext {
205
251
  apiKeyId: DocumentId;
206
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.7",
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",
@@ -101,4 +102,4 @@
101
102
  "npm": true,
102
103
  "npmAccess": "public"
103
104
  }
104
- }
105
+ }