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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +328 -211
  3. package/dist/api-keys/fields.mjs +22 -5
  4. package/dist/api-keys/setup-guide.mjs +6 -4
  5. package/dist/auth/resolve.mjs +5 -7
  6. package/dist/capabilities.mjs +23 -4
  7. package/dist/client/index.d.mts +2 -2
  8. package/dist/client/setup-guide.d.mts +1 -1
  9. package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
  10. package/dist/endpoint/handler.mjs +11 -5
  11. package/dist/endpoint/index.mjs +4 -0
  12. package/dist/endpoint/server.mjs +18 -26
  13. package/dist/i18n.mjs +4 -15
  14. package/dist/index.d.mts +4 -4
  15. package/dist/index.mjs +4 -3
  16. package/dist/options.mjs +30 -26
  17. package/dist/plugin.mjs +1 -0
  18. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  19. package/dist/request.mjs +8 -0
  20. package/dist/result.d.mts +11 -0
  21. package/dist/result.mjs +20 -0
  22. package/dist/schema/describe.mjs +3 -15
  23. package/dist/schema/index.mjs +8 -0
  24. package/dist/schema/lexical-pointer.mjs +125 -0
  25. package/dist/schema/lexical.mjs +195 -27
  26. package/dist/schema/outline.mjs +67 -0
  27. package/dist/schema/pointer.mjs +77 -30
  28. package/dist/schema/shape.mjs +133 -51
  29. package/dist/schema/walk.mjs +44 -64
  30. package/dist/tools/{index.mjs → builtin.mjs} +8 -5
  31. package/dist/tools/create-document.mjs +34 -15
  32. package/dist/tools/describe-schema.mjs +21 -7
  33. package/dist/tools/find-documents.mjs +13 -6
  34. package/dist/tools/get-document.mjs +45 -11
  35. package/dist/tools/list-capabilities.mjs +19 -9
  36. package/dist/tools/names.mjs +2 -1
  37. package/dist/tools/patch-document.mjs +32 -21
  38. package/dist/tools/publish-document.mjs +79 -0
  39. package/dist/tools/shared.mjs +84 -32
  40. package/dist/tools/target.mjs +7 -11
  41. package/dist/tools/validate-document.mjs +20 -12
  42. package/dist/types.d.mts +110 -42
  43. package/dist/types.mjs +3 -4
  44. package/dist/version.mjs +1 -1
  45. package/dist/write/draft-guard.mjs +47 -44
  46. package/dist/write/patch.mjs +174 -92
  47. package/dist/write/publish-blockers.mjs +13 -12
  48. package/dist/write/publish-intent.mjs +17 -0
  49. package/dist/write/transaction.mjs +8 -3
  50. package/package.json +3 -3
  51. package/dist/i18n.d.mts +0 -1
  52. package/dist/options.d.mts +0 -2
  53. package/dist/schema/lexical.d.mts +0 -1
  54. package/dist/schema/walk.d.mts +0 -3
  55. package/dist/tools/target.d.mts +0 -3
  56. package/dist/tools/types.d.mts +0 -5
  57. package/dist/write/publish-blockers.d.mts +0 -15
@@ -1,9 +1,16 @@
1
- import { jsonResult } from "../endpoint/result.mjs";
1
+ import { jsonResult } from "../result.mjs";
2
2
  import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
3
  import { resolveTarget } from "./target.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
4
5
  import { z } from "zod";
5
- //#region src/tools/find-documents.ts
6
- const findDocuments = {
6
+ /**
7
+ * Collection-only: a global is a singleton, so there is nothing to list.
8
+ *
9
+ * The query goes to Payload with `overrideAccess: false`, so the collection's
10
+ * own access control decides what comes back. `limit` and `depth` are bounded
11
+ * by the configured limits in the schema itself, which puts the ceiling in
12
+ * front of the client rather than silently clamping behind it.
13
+ */ const findDocuments = defineMcpxTool({
7
14
  name: "findDocuments",
8
15
  description: `Finds documents in a collection. "where" is a Payload query object, e.g. {"title":{"contains":"home"}} or {"and":[...]}; "select" picks fields, e.g. {"title":true}. Drafts are included by default so unpublished work is visible. Keep depth at 0 unless populated relationships are needed; ids are enough for writes.`,
9
16
  annotations: {
@@ -15,7 +22,7 @@ const findDocuments = {
15
22
  collection: slugEnum(scope.readable).describe("Collection to search."),
16
23
  where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
17
24
  sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
18
- limit: z.number().int().min(1).max(scope.options.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.options.limits.maxLimit)}.`),
25
+ limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.limits.maxLimit)}.`),
19
26
  page: z.number().int().min(1).optional().describe("Page number, from 1."),
20
27
  ...depthShape(scope),
21
28
  select: z.record(z.string(), z.unknown()).optional().describe("Fields to return, e.g. {\"title\":true}."),
@@ -25,7 +32,7 @@ const findDocuments = {
25
32
  }),
26
33
  draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
27
34
  }),
28
- handler: async (args, scope) => {
35
+ handler: async ({ args, scope }) => {
29
36
  resolveTarget(scope, { collection: args.collection }, "read");
30
37
  const locale = localeOf(scope, args.locale);
31
38
  const result = await scope.req.payload.find({
@@ -50,6 +57,6 @@ const findDocuments = {
50
57
  hasNextPage: result.hasNextPage
51
58
  });
52
59
  }
53
- };
60
+ });
54
61
  //#endregion
55
62
  export { findDocuments };
@@ -1,15 +1,26 @@
1
- import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
2
- import { errorResult, jsonResult } from "../endpoint/result.mjs";
1
+ import { errorResult, jsonResult } from "../result.mjs";
2
+ import { JSON_POINTER_PATTERN, findRichTextField, splitPath } from "../schema/walk.mjs";
3
+ import { lexicalOutline } from "../schema/outline.mjs";
4
+ import { resolveDataPointer } from "../schema/pointer.mjs";
5
+ import "../schema/index.mjs";
3
6
  import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
4
- import { requireIdFor, resolveTarget } from "./target.mjs";
7
+ import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
8
+ import { defineMcpxTool } from "../types.mjs";
5
9
  import { z } from "zod";
6
10
  import { Pointer } from "rfc6902";
7
11
  //#region src/tools/get-document.ts
8
- const getDocument = {
12
+ const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
13
+ /**
14
+ * With `path` the handler returns the subtree plus the `id`, `_status` and
15
+ * `updatedAt` a client needs to write back, so a caller reading one branch
16
+ * still gets the timestamp `expectedUpdatedAt` wants without a second call.
17
+ */ const getDocument = defineMcpxTool({
9
18
  name: "getDocument",
10
19
  description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
11
20
 
12
- Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.`,
21
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
22
+
23
+ Set "outline" on a rich text "path" to get a compact positional listing of its nodes instead of the raw editor state.`,
13
24
  annotations: {
14
25
  readOnlyHint: true,
15
26
  openWorldHint: false
@@ -27,9 +38,10 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
27
38
  required: false,
28
39
  description: "Locale to read. Defaults to the default locale."
29
40
  }),
30
- draft: z.boolean().optional().describe("Return the latest draft. Default true.")
41
+ draft: z.boolean().optional().describe("Return the latest draft. Default true."),
42
+ outline: z.boolean().optional().describe("For a rich text field, return a compact positional outline instead of the editor state. Requires \"path\".")
31
43
  }),
32
- handler: async (args, scope) => {
44
+ handler: async ({ args, scope }) => {
33
45
  const target = resolveTarget(scope, args, "read");
34
46
  const id = requireIdFor(target, args.id);
35
47
  const locale = localeOf(scope, args.locale);
@@ -48,21 +60,43 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
48
60
  ...shared,
49
61
  slug: target.slug
50
62
  }));
51
- if (args.path === void 0 || args.path === "") return jsonResult(doc);
63
+ if (args.path === void 0 || args.path === "") {
64
+ if (args.outline) return errorResult(OUTLINE_ERROR);
65
+ return jsonResult(doc);
66
+ }
52
67
  let value;
53
68
  try {
54
69
  value = Pointer.fromJSON(args.path).get(doc);
55
70
  } catch {
56
71
  return errorResult(`"${args.path}" is not a valid JSON pointer.`);
57
72
  }
58
- return jsonResult({
73
+ const envelope = {
59
74
  ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
60
75
  status: doc["_status"],
61
76
  updatedAt: doc["updatedAt"],
62
- path: args.path,
77
+ path: args.path
78
+ };
79
+ if (!args.outline) return jsonResult({
80
+ ...envelope,
63
81
  value
64
82
  });
83
+ let resolution;
84
+ try {
85
+ resolution = resolveDataPointer(scope.req.payload.config, {
86
+ doc,
87
+ pointer: args.path,
88
+ ref: refOf(target)
89
+ });
90
+ } catch (error) {
91
+ return errorResult(error instanceof Error ? error.message : OUTLINE_ERROR);
92
+ }
93
+ const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
94
+ if (!field) return errorResult(OUTLINE_ERROR);
95
+ return jsonResult({
96
+ ...envelope,
97
+ outline: lexicalOutline(value, args.path, field)
98
+ });
65
99
  }
66
- };
100
+ });
67
101
  //#endregion
68
102
  export { getDocument };
@@ -1,11 +1,18 @@
1
+ import { canCreate } from "../capabilities.mjs";
2
+ import { jsonResult } from "../result.mjs";
1
3
  import { translatorFor } from "../i18n.mjs";
2
- import { jsonResult } from "../endpoint/result.mjs";
3
4
  import { translateLabel } from "./shared.mjs";
5
+ import { defineMcpxTool } from "../types.mjs";
4
6
  import { hasDraftValidationEnabled } from "payload/shared";
5
- //#region src/tools/list-capabilities.ts
6
- const listCapabilities = {
7
+ /**
8
+ * Registered for every key, including one with no capabilities ticked, so a
9
+ * client always has something to call and gets an empty surface described
10
+ * rather than an empty tool list. The response is assembled from the request
11
+ * scope and the sanitized config, never from the content model, so it stays the
12
+ * same size as a deployment grows.
13
+ */ const listCapabilities = defineMcpxTool({
7
14
  name: "listCapabilities",
8
- description: `Lists what this key may do: the collections and globals it can read or write, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
15
+ description: `Lists what this key may do: the collections and globals it can read or write, whether a collection can also be created in, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
9
16
 
10
17
  A global is a singleton: it has no id, is not listed by findDocuments and cannot be created. Address one with the "global" argument where a collection document would take "collection" and "id".`,
11
18
  annotations: {
@@ -14,10 +21,10 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
14
21
  },
15
22
  isEnabled: () => true,
16
23
  inputSchema: () => ({}),
17
- handler: (_args, scope) => {
24
+ handler: ({ scope }) => {
18
25
  const { payload } = scope.req;
19
26
  const translate = translatorFor(scope.req.i18n);
20
- const collections = scope.options.collections.flatMap((entry) => {
27
+ const collections = scope.exposure.collections.flatMap((entry) => {
21
28
  const capability = scope.capabilities.collections[entry.slug];
22
29
  const collection = payload.collections[entry.slug];
23
30
  if (!capability || !collection || !(capability.read || capability.write)) return [];
@@ -32,12 +39,14 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
32
39
  ...description === void 0 ? {} : { description },
33
40
  read: capability.read,
34
41
  write: capability.write,
42
+ create: capability.write && canCreate(entry),
43
+ publish: capability.publish,
35
44
  drafts: entry.hasDrafts,
36
45
  draftValidation: hasDraftValidationEnabled(config),
37
46
  idType: collection.customIDType ?? payload.db.defaultIDType
38
47
  }];
39
48
  });
40
- const globals = scope.options.globals.flatMap((entry) => {
49
+ const globals = scope.exposure.globals.flatMap((entry) => {
41
50
  const capability = scope.capabilities.globals[entry.slug];
42
51
  const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
43
52
  if (!capability || !config || !(capability.read || capability.write)) return [];
@@ -48,6 +57,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
48
57
  ...description === void 0 ? {} : { description },
49
58
  read: capability.read,
50
59
  write: capability.write,
60
+ publish: capability.publish,
51
61
  drafts: entry.hasDrafts,
52
62
  draftValidation: hasDraftValidationEnabled(config)
53
63
  }];
@@ -59,10 +69,10 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
59
69
  codes: scope.locales,
60
70
  default: scope.defaultLocale
61
71
  } : null,
62
- limits: scope.options.limits,
72
+ limits: scope.limits,
63
73
  tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
64
74
  }));
65
75
  }
66
- };
76
+ });
67
77
  //#endregion
68
78
  export { listCapabilities };
@@ -8,7 +8,8 @@
8
8
  "getDocument",
9
9
  "patchDocument",
10
10
  "createDocument",
11
- "validateDocument"
11
+ "validateDocument",
12
+ "publishDocument"
12
13
  ];
13
14
  //#endregion
14
15
  export { BUILTIN_TOOL_NAMES };
@@ -1,24 +1,28 @@
1
- import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
1
+ import { errorResult, jsonResult } from "../result.mjs";
2
+ import { draftSentence, idShape, localeOf, localeShape, readTarget, sameInstant, targetShape } from "./shared.mjs";
3
3
  import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
4
- import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
5
+ import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
5
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
6
7
  import { withTransaction } from "../write/transaction.mjs";
7
8
  import { z } from "zod";
8
9
  import { Pointer } from "rfc6902";
9
10
  //#region src/tools/patch-document.ts
10
- const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
11
+ const DESCRIPTION = (scope) => `Applies RFC 6902 JSON Patch operations to one document.
11
12
 
12
13
  Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
13
14
 
14
- The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
15
+ ${draftSentence(scope)}
15
16
 
16
- Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
17
+ Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index. Inside a rich text field that substitution does not apply: a path there names the node type, and a block node its slug, where a pointer enters the stored state at "root" and walks "children" by an index counted over every child at that level, not over the blocks among them, with the node's own fields under "fields". So "/content/block/practice-note/variant" is written at "/content/root/children/7/fields/variant".
17
18
 
18
- Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so a concurrent edit is refused rather than overwritten.
19
+ Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so an edit made since that read is refused rather than overwritten.
19
20
 
20
- A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but a human cannot publish the document until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update.`;
21
- const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
21
+ Inside a rich text field a pointer keeps going: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties, and "/content/root/children/2/fields/url" a field it carries. A node written at a position must carry everything Lexical serializes, "version" included, exactly as one written inside a whole state must; getDocument with "outline" returns each node's pointer and version, which is the cheapest way to get both right. A node's "type" cannot be replaced on its own, and neither can the root.
22
+
23
+ Node positions shift as soon as anything is added or removed, so read immediately before patching, order removals from the last index to the first, and use a "test" operation on "/content/root/children/2/type" to assert a position is what you think it is before writing to it.
24
+
25
+ A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but the document cannot be published until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
22
26
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
23
27
  /**
24
28
  * Whether the intended value survived the write. The saved document is
@@ -42,12 +46,18 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
42
46
  const actual = pointer.get(saved);
43
47
  return survives(expected, actual) ? [] : [operation.path];
44
48
  });
45
- const patchDocument = {
49
+ /**
50
+ * The handler validates the whole batch against the schema and the current
51
+ * document before it writes anything, runs the write in a transaction, then
52
+ * re-reads the saved document to report which pointers survived and what still
53
+ * blocks publishing. Nothing here decides where the write lands: the draft
54
+ * guard does that on the Payload operation.
55
+ */ const patchDocument = defineMcpxTool({
46
56
  name: "patchDocument",
47
57
  description: DESCRIPTION,
48
58
  annotations: {
49
59
  readOnlyHint: false,
50
- destructiveHint: false,
60
+ destructiveHint: true,
51
61
  idempotentHint: false,
52
62
  openWorldHint: false
53
63
  },
@@ -63,13 +73,14 @@ const patchDocument = {
63
73
  description: "Locale the patch applies to. Localized fields write here only."
64
74
  }),
65
75
  patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
66
- expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
76
+ expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. Best effort: the write is refused if the document changed before the check, but not if it changes between the check and the write.")
67
77
  }),
68
- handler: async (args, scope) => {
78
+ handler: async ({ args, scope }) => {
69
79
  const target = resolveTarget(scope, args, "write");
70
80
  const id = requireIdFor(target, args.id);
71
81
  const { payload } = scope.req;
72
82
  const locale = localeOf(scope, args.locale);
83
+ const patches = args.patches;
73
84
  return await withTransaction(scope.req, async () => {
74
85
  const doc = await readTarget(scope, {
75
86
  target,
@@ -77,13 +88,11 @@ const patchDocument = {
77
88
  locale
78
89
  });
79
90
  if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again and re-apply the patch.", { updatedAt: doc["updatedAt"] });
80
- const problems = findPatchProblems(payload.config, {
91
+ const applied = applyPatchOperations(payload.config, {
81
92
  doc,
82
- patches: args.patches,
93
+ patches,
83
94
  ref: refOf(target)
84
95
  });
85
- if (problems.length > 0) return errorResult("No operation was applied.", { problems });
86
- const applied = applyPatchToCopy(doc, args.patches);
87
96
  if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
88
97
  const write = {
89
98
  data: buildWriteData(payload.config, target.config, applied.next),
@@ -100,6 +109,7 @@ const patchDocument = {
100
109
  });
101
110
  else await payload.updateGlobal({
102
111
  ...write,
112
+ fallbackLocale: false,
103
113
  slug: target.slug
104
114
  });
105
115
  const saved = await readTarget(scope, {
@@ -108,8 +118,8 @@ const patchDocument = {
108
118
  locale,
109
119
  privileged: true
110
120
  });
111
- const notApplied = notAppliedPointers(args.patches, applied.next, saved);
112
- const publishBlockers = await collectPublishBlockers(scope.req, {
121
+ const notApplied = notAppliedPointers(patches, applied.next, saved);
122
+ const validation = await collectPublishBlockers(scope.req, {
113
123
  doc: saved,
114
124
  entity: target
115
125
  });
@@ -117,11 +127,12 @@ const patchDocument = {
117
127
  ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
118
128
  status: saved["_status"],
119
129
  updatedAt: saved["updatedAt"],
120
- ...publishBlockers.length > 0 ? { publishBlockers } : {},
130
+ ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
131
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {},
121
132
  ...notApplied.length > 0 ? { notApplied } : {}
122
133
  });
123
134
  });
124
135
  }
125
- };
136
+ });
126
137
  //#endregion
127
138
  export { patchDocument };
@@ -0,0 +1,79 @@
1
+ import { errorResult, jsonResult } from "../result.mjs";
2
+ import { idShape, localeOf, readTarget, sameInstant, targetShape } from "./shared.mjs";
3
+ import { requireIdFor, resolveTarget } from "./target.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
5
+ import { withTransaction } from "../write/transaction.mjs";
6
+ import { withPublishIntent } from "../write/publish-intent.mjs";
7
+ import { z } from "zod";
8
+ /**
9
+ * The only tool that changes live content, available where the config sets
10
+ * `write: "live"` on a versioned entity and the key has both the `write` and
11
+ * `publish` checkboxes.
12
+ */ const publishDocument = defineMcpxTool({
13
+ name: "publishDocument",
14
+ description: `Publishes the current draft, which changes what the public sees. This is the only tool that does; every other write lands as a draft. Call validateDocument first: a document that still has publish blockers is refused, and nothing is written.
15
+
16
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
17
+
18
+ The whole document is published, but Payload only validates the locale the publish runs in, so a required field left empty in another locale goes live empty. That is how the admin panel behaves too. Publishing is refused while a human holds the document open in the admin panel, and republishing an unchanged document is accepted but writes another version.
19
+
20
+ There is no unpublish: reverting to a draft stays a human action in the admin panel.`,
21
+ annotations: {
22
+ destructiveHint: true,
23
+ openWorldHint: false
24
+ },
25
+ isEnabled: (scope) => scope.publishable.length + scope.publishableGlobals.length > 0,
26
+ inputSchema: (scope) => ({
27
+ ...targetShape(scope, "publish", {
28
+ collection: "Collection holding the document.",
29
+ global: "Global to publish."
30
+ }),
31
+ ...idShape(scope, "publish"),
32
+ expectedUpdatedAt: z.string().optional().describe("The updatedAt read before publishing. Best effort: the publish is refused if the document has changed since, but a write landing between the check and the publish is not.")
33
+ }),
34
+ handler: async ({ args, scope }) => {
35
+ const target = resolveTarget(scope, args, "publish");
36
+ const id = requireIdFor(target, args.id);
37
+ const { payload } = scope.req;
38
+ const locale = localeOf(scope, void 0);
39
+ return await withTransaction(scope.req, async () => {
40
+ const doc = await readTarget(scope, {
41
+ target,
42
+ id,
43
+ locale
44
+ });
45
+ if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again before publishing.", { updatedAt: doc["updatedAt"] });
46
+ const write = {
47
+ data: withPublishIntent({}),
48
+ depth: 0,
49
+ draft: false,
50
+ fallbackLocale: false,
51
+ overrideAccess: false,
52
+ req: scope.req,
53
+ ...locale === void 0 ? {} : { locale }
54
+ };
55
+ if (target.kind === "collection") await payload.update({
56
+ ...write,
57
+ collection: target.slug,
58
+ id
59
+ });
60
+ else await payload.updateGlobal({
61
+ ...write,
62
+ slug: target.slug
63
+ });
64
+ const saved = await readTarget(scope, {
65
+ target,
66
+ id,
67
+ locale,
68
+ privileged: true
69
+ });
70
+ return jsonResult({
71
+ ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
72
+ status: saved["_status"],
73
+ updatedAt: saved["updatedAt"]
74
+ });
75
+ });
76
+ }
77
+ });
78
+ //#endregion
79
+ export { publishDocument };
@@ -1,48 +1,102 @@
1
+ import { canCreate, canPublish, isLiveWrite } from "../capabilities.mjs";
1
2
  import { translateStatic } from "../i18n.mjs";
2
3
  import { NotFound } from "payload";
3
4
  import { z } from "zod";
4
5
  //#region src/tools/shared.ts
5
- const slugEnum = (slugs) => z.enum(slugs);
6
- const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
7
- const slugsFor = (scope, operation) => ({
8
- collections: operation === "read" ? scope.readable : scope.writable,
9
- globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
6
+ /**
7
+ * An out-of-scope slug fails schema validation before a handler runs, so a
8
+ * client only ever sees what its key may touch.
9
+ */ const slugEnum = (slugs) => z.enum(slugs);
10
+ /** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
11
+ const slugsWhere = (scope, predicate, allowed) => {
12
+ const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
13
+ return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
14
+ };
15
+ /**
16
+ * Slugs this key may write whose writes land live rather than as a draft. An
17
+ * entity without versions has no draft to land on, so `write: "live"` there
18
+ * makes every write a live one. Empty for every key that can only write drafts.
19
+ */ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
20
+ collections: scope.writable,
21
+ globals: scope.writableGlobals
22
+ });
23
+ /**
24
+ * Slugs this key may write but never create in, because their documents are
25
+ * files. Collection-only, since nothing creates a global either way.
26
+ */ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
27
+ collections: scope.writable,
28
+ globals: []
29
+ });
30
+ /** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
31
+ collections: scope.publishable,
32
+ globals: scope.publishableGlobals
10
33
  });
11
34
  /**
12
- * The `collection` and `global` arguments.
13
- *
14
- * When the key can reach no global, `global` is left out of the shape entirely
15
- * and `collection` stays required, mirroring how {@link localeShape} omits
16
- * `locale` when localization is off. A deployment without globals therefore
17
- * sees exactly the schema it saw before. Only the mixed case makes either
18
- * argument optional, and the handler enforces the exclusivity there.
35
+ * What a write actually does for this key, and what it takes to make it public.
36
+ * A live-write slug has no draft and no publish step; a publishable one has
37
+ * both. Stated per key so a client is never told its writes are drafts while
38
+ * they are not, nor that publishing is out of reach when it is not.
39
+ */ const draftSentence = (scope) => {
40
+ const live = liveWriteSlugs(scope);
41
+ const publishable = publishableWriteSlugs(scope);
42
+ return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
43
+ };
44
+ /** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
45
+ /** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
46
+ /** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
47
+ switch (operation) {
48
+ case "create": return {
49
+ collections: slugsWhere(scope, canCreate, {
50
+ collections: scope.writable,
51
+ globals: []
52
+ }),
53
+ globals: []
54
+ };
55
+ case "publish": return {
56
+ collections: scope.publishable,
57
+ globals: scope.publishableGlobals
58
+ };
59
+ case "read": return {
60
+ collections: scope.readable,
61
+ globals: scope.readableGlobals
62
+ };
63
+ case "write": return {
64
+ collections: scope.writable,
65
+ globals: scope.writableGlobals
66
+ };
67
+ }
68
+ };
69
+ /**
70
+ * With no reachable global, `global` is left out and `collection` stays
71
+ * required, so a deployment without globals sees an unchanged schema. Only the
72
+ * mixed case makes either optional, and the handler enforces exclusivity there.
19
73
  */ const targetShape = (scope, operation, descriptions) => {
20
74
  const { collections, globals } = slugsFor(scope, operation);
21
- if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
22
- if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
23
- return {
75
+ if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
76
+ if (collections.length === 0) return widen({ global: slugEnum(globals).describe(descriptions.global) });
77
+ return widen({
24
78
  collection: slugEnum(collections).optional().describe(descriptions.collection),
25
79
  global: slugEnum(globals).optional().describe(descriptions.global)
26
- };
80
+ });
27
81
  };
28
82
  /**
29
- * The `id` argument, which only a collection document has. Omitted when the key
30
- * can reach no collection, required when it can reach no global, and optional
31
- * in between, where `requireIdFor` enforces the dependency.
83
+ * Only a collection document has one. Optional in the mixed case, where
84
+ * `requireIdFor` enforces the dependency.
32
85
  */ const idShape = (scope, operation) => {
33
86
  const { collections, globals } = slugsFor(scope, operation);
34
- if (collections.length === 0) return {};
35
- if (globals.length === 0) return { id: idSchema };
36
- return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
87
+ if (collections.length === 0) return widen({});
88
+ if (globals.length === 0) return widen({ id: idSchema });
89
+ return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
37
90
  };
38
- /**
39
- * The `locale` argument, present only when localization is configured.
40
- */ const localeShape = (scope, options) => {
41
- if (!scope.locales) return {};
91
+ const localeShape = (scope, options) => {
92
+ if (!scope.locales) return widen({});
42
93
  const locale = z.enum(scope.locales);
43
- return { locale: (options.required ? locale : locale.optional()).describe(options.description) };
94
+ return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
44
95
  };
45
- const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.options.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.options.limits.maxDepth)}.`) });
96
+ /**
97
+ * Defaults to 0 rather than Payload's own default: a client usually wants ids
98
+ * it can write back, and populating a relation costs a query.
99
+ */ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
46
100
  /**
47
101
  * The locale to operate on: the explicit argument, else the request's, else
48
102
  * the default. `undefined` when localization is off.
@@ -83,9 +137,7 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
83
137
  slug: args.target.slug
84
138
  });
85
139
  };
86
- /**
87
- * Resolves a collection label for the request's language.
88
- */ const translateLabel = (scope, label, fallback) => {
140
+ const translateLabel = (scope, label, fallback) => {
89
141
  const { i18n, t } = scope.req;
90
142
  const resolved = typeof label === "function" ? label({
91
143
  i18n,
@@ -94,4 +146,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
94
146
  return translateStatic(resolved, i18n) ?? fallback;
95
147
  };
96
148
  //#endregion
97
- export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
149
+ export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, patchOnlySlugs, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
@@ -1,3 +1,4 @@
1
+ import { slugsFor } from "./shared.mjs";
1
2
  import { APIError, Forbidden } from "payload";
2
3
  //#region src/tools/target.ts
3
4
  const refOf = (target) => ({
@@ -5,21 +6,17 @@ const refOf = (target) => ({
5
6
  slug: target.slug
6
7
  });
7
8
  /**
8
- * Resolves the `collection`/`global` arguments to one entity and checks the key
9
- * may perform `operation` on it.
10
- *
11
- * A tool's `inputSchema` returns a raw shape, which leaves no top-level
12
- * `.refine` to express "exactly one of collection and global". The rule is
13
- * enforced here instead, with a message naming the offending arguments so one
14
- * failed call teaches it.
9
+ * A raw input shape leaves no top-level `.refine` to express "exactly one of
10
+ * collection and global", so the rule is enforced here, with a message naming
11
+ * the offending arguments.
15
12
  */ const resolveTarget = (scope, args, operation) => {
16
13
  const { collection, global } = args;
14
+ const allowedSlugs = slugsFor(scope, operation);
17
15
  if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
18
16
  if (collection === void 0 && global === void 0) throw new APIError("One of \"collection\" or \"global\" is required. Call listCapabilities to see which slugs are available.", 400);
19
17
  if (collection !== void 0) {
20
- const allowed = operation === "read" ? scope.readable : scope.writable;
21
18
  const found = scope.req.payload.collections[collection];
22
- if (!allowed.includes(collection) || !found) throw new Forbidden(scope.req.t);
19
+ if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
23
20
  return {
24
21
  kind: "collection",
25
22
  slug: collection,
@@ -27,9 +24,8 @@ const refOf = (target) => ({
27
24
  };
28
25
  }
29
26
  const slug = global;
30
- const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
31
27
  const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
32
- if (!allowed.includes(slug) || !found) throw new Forbidden(scope.req.t);
28
+ if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
33
29
  return {
34
30
  kind: "global",
35
31
  slug,