@abinnovision/payloadcms-mcpx 1.1.0-beta.5 → 2.0.0-beta.10

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 (71) hide show
  1. package/README.md +140 -448
  2. package/dist/admin/capability-matrix.d.mts +33 -0
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +4 -2
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +12 -8
  8. package/dist/api-keys/capability-matrix.mjs +17 -85
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +44 -6
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +2 -1
  13. package/dist/capabilities.mjs +41 -43
  14. package/dist/define-tool.d.mts +23 -0
  15. package/dist/{types.mjs → define-tool.mjs} +1 -1
  16. package/dist/endpoint/errors.mjs +5 -6
  17. package/dist/endpoint/handler.mjs +49 -19
  18. package/dist/endpoint/server.mjs +72 -20
  19. package/dist/entity.d.mts +9 -0
  20. package/dist/guards.mjs +7 -0
  21. package/dist/i18n.mjs +10 -8
  22. package/dist/index.d.mts +5 -2
  23. package/dist/index.mjs +3 -2
  24. package/dist/options.mjs +52 -44
  25. package/dist/plugin.mjs +0 -1
  26. package/dist/request.d.mts +2 -3
  27. package/dist/request.mjs +2 -3
  28. package/dist/result.d.mts +2 -3
  29. package/dist/result.mjs +3 -3
  30. package/dist/schema/admin-hidden.mjs +91 -0
  31. package/dist/schema/describe.mjs +17 -33
  32. package/dist/schema/errors.mjs +8 -0
  33. package/dist/schema/index.mjs +5 -2
  34. package/dist/schema/lexical-pointer.mjs +18 -20
  35. package/dist/schema/lexical.mjs +44 -93
  36. package/dist/schema/outline.mjs +5 -15
  37. package/dist/schema/path.mjs +28 -0
  38. package/dist/schema/pointer.mjs +18 -25
  39. package/dist/schema/shape.mjs +25 -71
  40. package/dist/schema/walk.mjs +43 -45
  41. package/dist/tools/builtin.mjs +6 -4
  42. package/dist/tools/create-document.mjs +26 -23
  43. package/dist/tools/describe-schema.mjs +38 -34
  44. package/dist/tools/document.mjs +124 -0
  45. package/dist/tools/find-documents.mjs +32 -19
  46. package/dist/tools/find-versions.mjs +74 -0
  47. package/dist/tools/get-document.mjs +80 -49
  48. package/dist/tools/list-capabilities.mjs +25 -16
  49. package/dist/tools/patch-document.mjs +75 -49
  50. package/dist/tools/publish-document.mjs +26 -32
  51. package/dist/tools/query-paths.mjs +57 -0
  52. package/dist/tools/read-request.d.mts +10 -0
  53. package/dist/tools/read-request.mjs +43 -0
  54. package/dist/tools/shared.mjs +74 -96
  55. package/dist/tools/validate-document.mjs +18 -30
  56. package/dist/tools/versions.mjs +136 -0
  57. package/dist/types.d.mts +119 -73
  58. package/dist/write/draft-guard.mjs +23 -41
  59. package/dist/write/patch.mjs +27 -183
  60. package/dist/write/publish-blockers.mjs +7 -14
  61. package/dist/write/publish-intent.mjs +6 -4
  62. package/dist/write/row-ids.mjs +46 -0
  63. package/dist/write/transaction.mjs +5 -8
  64. package/dist/write/write-data.mjs +64 -0
  65. package/package.json +6 -5
  66. package/dist/client/capability-matrix.d.mts +0 -26
  67. package/dist/client/capability-matrix.mjs +0 -349
  68. package/dist/endpoint/index.mjs +0 -4
  69. package/dist/tools/target.mjs +0 -45
  70. /package/dist/{client → admin}/index.d.mts +0 -0
  71. /package/dist/{client → admin}/index.mjs +0 -0
@@ -0,0 +1,74 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
2
+ import { jsonResult } from "../result.mjs";
3
+ import { draftVersionSlugs, entityShape, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
4
+ import { resolveDocument } from "./document.mjs";
5
+ import { queryVersions } from "./versions.mjs";
6
+ import { APIError } from "payload";
7
+ import { hasDraftsEnabled } from "payload/shared";
8
+ import { z } from "zod";
9
+ //#region src/tools/find-versions.ts
10
+ const DESCRIPTION = `Lists the versions of one document or global, newest first, autosaves included. Returns per version "versionId", "createdAt", "updatedAt", "status", "latest", "autosave" and, where set, "publishedLocale", without the content. Read a version with getDocument "versionId", or compare it with "diffFrom".`;
11
+ const statusShape = (scope) => {
12
+ const { collections, globals } = draftVersionSlugs(scope);
13
+ if (collections.length + globals.length === 0) return widen({});
14
+ return widen({ status: z.enum(["published", "draft"]).optional() });
15
+ };
16
+ /**
17
+ * Bodies are left out so a long history stays small; `getDocument` reads one
18
+ * when it is needed. The document's `read` access is checked first, then
19
+ * Payload's `readVersions`. Only entities that keep Payload versions are
20
+ * reachable.
21
+ */ const findVersions = defineMcpxTool({
22
+ name: "findVersions",
23
+ description: DESCRIPTION,
24
+ annotations: {
25
+ readOnlyHint: true,
26
+ openWorldHint: false
27
+ },
28
+ isEnabled: (scope) => {
29
+ const { collections, globals } = slugsFor(scope, "versions");
30
+ return collections.length + globals.length > 0;
31
+ },
32
+ inputSchema: (scope) => ({
33
+ ...entityShape(scope, "versions"),
34
+ ...idShape(scope, "versions"),
35
+ limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe("Default 10."),
36
+ page: z.number().int().min(1).optional(),
37
+ ...statusShape(scope),
38
+ ...localeShape(scope, {
39
+ required: false,
40
+ description: "Locale whose \"status\" is reported. Defaults to the default locale."
41
+ })
42
+ }),
43
+ handler: async ({ args, scope }) => {
44
+ const target = resolveDocument(scope, args, "versions");
45
+ if (args.status !== void 0 && !hasDraftsEnabled(target.config)) throw new APIError(`"${target.slug}" has no drafts, so its versions carry no status.`, 400);
46
+ const result = await queryVersions(scope, {
47
+ target,
48
+ depth: 0,
49
+ locale: localeOf(scope, args.locale)
50
+ }, {
51
+ limit: args.limit ?? 10,
52
+ ...args.page === void 0 ? {} : { page: args.page },
53
+ ...args.status === void 0 ? {} : { status: args.status }
54
+ });
55
+ const versions = result.docs.map((doc) => ({
56
+ versionId: doc.id,
57
+ createdAt: doc["createdAt"],
58
+ updatedAt: doc["updatedAt"],
59
+ status: doc.version["_status"] ?? null,
60
+ latest: doc["latest"] === true,
61
+ autosave: doc["autosave"] === true,
62
+ ...doc["publishedLocale"] ? { publishedLocale: doc["publishedLocale"] } : {}
63
+ }));
64
+ return jsonResult({
65
+ versions,
66
+ totalDocs: result.totalDocs,
67
+ page: result.page,
68
+ totalPages: result.totalPages,
69
+ hasNextPage: result.hasNextPage
70
+ });
71
+ }
72
+ });
73
+ //#endregion
74
+ export { findVersions };
@@ -1,80 +1,110 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
1
2
  import { errorResult, jsonResult } from "../result.mjs";
2
- import { JSON_POINTER_PATTERN, findRichTextField, splitPath } from "../schema/walk.mjs";
3
+ import { JSON_POINTER_PATTERN, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
4
+ import { findRichTextField } from "../schema/walk.mjs";
5
+ import { SchemaError } from "../schema/errors.mjs";
3
6
  import { lexicalOutline } from "../schema/outline.mjs";
4
7
  import { resolveDataPointer } from "../schema/pointer.mjs";
5
8
  import "../schema/index.mjs";
6
- import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
7
- import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
8
- import { defineMcpxTool } from "../types.mjs";
9
+ import { READ_LOCALE_DESCRIPTION, depthShape, entityShape, idSchema, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
10
+ import { identityOf, resolveDocument } from "./document.mjs";
11
+ import { diffDocuments, loadPublished, loadVersion, readLive } from "./versions.mjs";
12
+ import { APIError } from "payload";
9
13
  import { z } from "zod";
10
14
  import { Pointer } from "rfc6902";
11
15
  //#region src/tools/get-document.ts
12
16
  const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
17
+ const VERSION_PARAGRAPH = `
18
+
19
+ "versionId" and "diffFrom" work where findVersions does. To revert, call getDocument with the old version as "versionId", the version findVersions marks "latest" as "diffFrom" and the locale you will write, then apply the returned "patch" with patchDocument.`;
20
+ const DESCRIPTION = `Reads one document or global, or with "path" only the value at that pointer. Returns the latest draft by default, with the "updatedAt" a write takes as "expectedUpdatedAt".`;
21
+ const exposesVersions = (scope) => {
22
+ const { collections, globals } = slugsFor(scope, "versions");
23
+ return collections.length + globals.length > 0;
24
+ };
25
+ const assertVersionArgs = (scope, target, args) => {
26
+ if (args.versionId === void 0 && args.diffFrom === void 0) return;
27
+ const { collections, globals } = slugsFor(scope, "versions");
28
+ if (!(target.kind === "collection" ? collections : globals).includes(target.slug)) throw new APIError(`"${target.slug}" does not expose version history.`, 400);
29
+ if (args.versionId !== void 0 && args.draft !== void 0) throw new APIError("Pass either \"versionId\" or \"draft\", not both.", 400);
30
+ if (args.diffFrom !== void 0 && args.outline) throw new APIError("\"outline\" cannot be combined with \"diffFrom\".", 400);
31
+ };
32
+ const diffResult = async (scope, read, diff, doc) => {
33
+ const from = diff.from === "published" ? await loadPublished(scope, read) : await loadVersion(scope, read, diff.from);
34
+ if (!from) return errorResult(diff.from === "published" ? `"${read.target.slug}" has no published version to diff from.` : `Version "${String(diff.from)}" not found.`);
35
+ return jsonResult({
36
+ from: from.id,
37
+ to: diff.to ?? "current",
38
+ ...diff.pointer === void 0 ? {} : { path: diff.pointer.toString() },
39
+ patch: diffDocuments(from.version, doc, diff.pointer)
40
+ });
41
+ };
42
+ const versionShape = (scope) => {
43
+ if (!exposesVersions(scope)) return widen({});
44
+ return widen({
45
+ versionId: idSchema.optional().describe("From findVersions. Not with \"draft\"."),
46
+ diffFrom: idSchema.optional().describe("Version id, or \"published\" for the newest version published in any locale. Returns {from, to, patch}, the JSON Patch from that version to what is read.")
47
+ });
48
+ };
13
49
  /**
14
50
  * 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.
51
+ * `updatedAt` a client needs to write back, so reading one branch still gives
52
+ * the `expectedUpdatedAt` timestamp without a second call.
17
53
  */ const getDocument = defineMcpxTool({
18
54
  name: "getDocument",
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.
20
-
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.`,
55
+ description: (scope) => exposesVersions(scope) ? DESCRIPTION + VERSION_PARAGRAPH : DESCRIPTION,
24
56
  annotations: {
25
57
  readOnlyHint: true,
26
58
  openWorldHint: false
27
59
  },
28
- isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
60
+ isEnabled: (scope) => scope.collections.readable.length + scope.globals.readable.length > 0,
29
61
  inputSchema: (scope) => ({
30
- ...targetShape(scope, "read", {
31
- collection: "Collection holding the document.",
32
- global: "Global to read."
33
- }),
62
+ ...entityShape(scope, "read"),
34
63
  ...idShape(scope, "read"),
35
- path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("JSON pointer to return only a subtree, e.g. \"/layout/sections/0\"."),
64
+ path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("e.g. \"/layout/sections/0\"."),
36
65
  ...depthShape(scope),
37
66
  ...localeShape(scope, {
38
67
  required: false,
39
- description: "Locale to read. Defaults to the default locale."
68
+ description: READ_LOCALE_DESCRIPTION
40
69
  }),
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\".")
70
+ draft: z.boolean().optional().describe("Default true. false reads the published version."),
71
+ outline: z.boolean().optional().describe("With \"path\" at a rich text field: list each node's pointer, type, \"version\" and text."),
72
+ ...versionShape(scope)
43
73
  }),
44
74
  handler: async ({ args, scope }) => {
45
- const target = resolveTarget(scope, args, "read");
46
- const id = requireIdFor(target, args.id);
47
- const locale = localeOf(scope, args.locale);
48
- const shared = {
75
+ const target = resolveDocument(scope, args, "read");
76
+ assertVersionArgs(scope, target, args);
77
+ const prototyped = args.path ? prototypeSegmentProblem(args.path) : void 0;
78
+ if (prototyped !== void 0) return errorResult(prototyped);
79
+ let pointer;
80
+ try {
81
+ pointer = args.path ? Pointer.fromJSON(args.path) : void 0;
82
+ } catch {
83
+ return errorResult(`"${String(args.path)}" is not a valid JSON pointer.`);
84
+ }
85
+ const read = {
86
+ target,
49
87
  depth: args.depth ?? 0,
50
- draft: args.draft ?? true,
51
- overrideAccess: false,
52
- req: scope.req,
53
- ...locale === void 0 ? {} : { locale }
88
+ locale: localeOf(scope, args.locale)
54
89
  };
55
- const doc = await (target.kind === "collection" ? scope.req.payload.findByID({
56
- ...shared,
57
- collection: target.slug,
58
- id
59
- }) : scope.req.payload.findGlobal({
60
- ...shared,
61
- slug: target.slug
62
- }));
63
- if (args.path === void 0 || args.path === "") {
90
+ const doc = args.versionId === void 0 ? await readLive(scope, read, { draft: args.draft ?? true }) : (await loadVersion(scope, read, args.versionId))?.version;
91
+ if (!doc) return errorResult(`Version "${String(args.versionId)}" not found.`);
92
+ if (args.diffFrom !== void 0) return await diffResult(scope, read, {
93
+ from: args.diffFrom,
94
+ to: args.versionId,
95
+ pointer
96
+ }, doc);
97
+ if (pointer === void 0) {
64
98
  if (args.outline) return errorResult(OUTLINE_ERROR);
65
99
  return jsonResult(doc);
66
100
  }
67
- let value;
68
- try {
69
- value = Pointer.fromJSON(args.path).get(doc);
70
- } catch {
71
- return errorResult(`"${args.path}" is not a valid JSON pointer.`);
72
- }
101
+ const path = pointer.toString();
102
+ const value = pointer.get(doc);
73
103
  const envelope = {
74
- ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
104
+ ...identityOf(target, args.id),
75
105
  status: doc["_status"],
76
106
  updatedAt: doc["updatedAt"],
77
- path: args.path
107
+ path
78
108
  };
79
109
  if (!args.outline) return jsonResult({
80
110
  ...envelope,
@@ -84,17 +114,18 @@ Set "outline" on a rich text "path" to get a compact positional listing of its n
84
114
  try {
85
115
  resolution = resolveDataPointer(scope.req.payload.config, {
86
116
  doc,
87
- pointer: args.path,
88
- ref: refOf(target)
117
+ pointer: path,
118
+ ref: target
89
119
  });
90
120
  } catch (error) {
91
- return errorResult(error instanceof Error ? error.message : OUTLINE_ERROR);
121
+ if (!(error instanceof SchemaError)) throw error;
122
+ return errorResult(error.message);
92
123
  }
93
124
  const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
94
125
  if (!field) return errorResult(OUTLINE_ERROR);
95
126
  return jsonResult({
96
127
  ...envelope,
97
- outline: lexicalOutline(value, args.path, field)
128
+ outline: lexicalOutline(value, path, field)
98
129
  });
99
130
  }
100
131
  });
@@ -1,20 +1,27 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
1
2
  import { canCreate } from "../capabilities.mjs";
2
3
  import { jsonResult } from "../result.mjs";
3
- import { translatorFor } from "../i18n.mjs";
4
- import { translateLabel } from "./shared.mjs";
5
- import { defineMcpxTool } from "../types.mjs";
4
+ import { translateStatic, translatorFor } from "../i18n.mjs";
6
5
  import { hasDraftValidationEnabled } from "payload/shared";
6
+ //#region src/tools/list-capabilities.ts
7
+ const DESCRIPTION = `Lists what this key may do. Call it first. Returns each collection and global the key can read or write, with whether it can create, publish, has drafts and exposes versions to findVersions, and a collection's "idType". Also returns the locales, the "limits" on page size and depth, and the enabled custom tools.`;
8
+ const translateLabel = (scope, label, fallback) => {
9
+ const { i18n, t } = scope.req;
10
+ const resolved = typeof label === "function" ? label({
11
+ i18n,
12
+ t
13
+ }) : label;
14
+ return translateStatic(resolved, i18n) ?? fallback;
15
+ };
7
16
  /**
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.
17
+ * Registered for every key, even one with no capabilities ticked, so a client
18
+ * always has a tool to call and gets an empty surface described instead of an
19
+ * empty tool list. The response comes from the request scope and the sanitized
20
+ * config, not the content model, so it stays the same size as a deployment
21
+ * grows.
13
22
  */ const listCapabilities = defineMcpxTool({
14
23
  name: "listCapabilities",
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.
16
-
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".`,
24
+ description: DESCRIPTION,
18
25
  annotations: {
19
26
  readOnlyHint: true,
20
27
  openWorldHint: false
@@ -42,6 +49,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
42
49
  create: capability.write && canCreate(entry),
43
50
  publish: capability.publish,
44
51
  drafts: entry.hasDrafts,
52
+ versions: entry.hasVersions,
45
53
  draftValidation: hasDraftValidationEnabled(config),
46
54
  idType: collection.customIDType ?? payload.db.defaultIDType
47
55
  }];
@@ -59,19 +67,20 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
59
67
  write: capability.write,
60
68
  publish: capability.publish,
61
69
  drafts: entry.hasDrafts,
70
+ versions: entry.hasVersions,
62
71
  draftValidation: hasDraftValidationEnabled(config)
63
72
  }];
64
73
  });
65
- return Promise.resolve(jsonResult({
74
+ return jsonResult({
66
75
  collections,
67
76
  ...globals.length > 0 ? { globals } : {},
68
- locales: scope.locales ? {
69
- codes: scope.locales,
70
- default: scope.defaultLocale
77
+ locales: scope.localization ? {
78
+ codes: scope.localization.locales,
79
+ default: scope.localization.defaultLocale
71
80
  } : null,
72
81
  limits: scope.limits,
73
82
  tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
74
- }));
83
+ });
75
84
  }
76
85
  });
77
86
  //#endregion
@@ -1,45 +1,69 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
2
+ import { isPlainObject } from "../guards.mjs";
1
3
  import { errorResult, jsonResult } from "../result.mjs";
2
- import { draftSentence, idShape, localeOf, localeShape, readTarget, sameInstant, targetShape } from "./shared.mjs";
3
- import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
4
- import { defineMcpxTool } from "../types.mjs";
5
- import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
4
+ import { JSON_POINTER_PATTERN } from "../schema/path.mjs";
5
+ import "../schema/index.mjs";
6
+ import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
6
7
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
8
+ import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
9
+ import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
7
10
  import { withTransaction } from "../write/transaction.mjs";
11
+ import { buildWriteData } from "../write/write-data.mjs";
12
+ import { Forbidden, NotFound } from "payload";
8
13
  import { z } from "zod";
9
14
  import { Pointer } from "rfc6902";
10
15
  //#region src/tools/patch-document.ts
11
- const DESCRIPTION = (scope) => `Applies RFC 6902 JSON Patch operations to one document.
16
+ const DESCRIPTION = (scope) => `Applies JSON Patch operations to one document or global, e.g. {"op":"replace","path":"/title","value":"Home"}. If any operation fails, nothing is written and the response lists the problems.
12
17
 
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.
18
+ ${liveWriteSentence(scope, "write")}
14
19
 
15
- ${draftSentence(scope)}
20
+ Read the document with getDocument right before patching. Append to a list with "/-". A new block needs "blockType". To clear a field, "replace" it with null, or with [] for an array or blocks field. "remove" only removes list elements. Indices shift with every add or remove, so remove from the last index to the first.
16
21
 
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".
22
+ In a rich text field a pointer continues into the rich text state: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties and "/content/root/children/3/fields/tone" a field of a block node. getDocument "outline" lists each node's pointer and "version". Build nodes from describeSchema "nodeProperties". Check a position before writing to it, e.g. {"op":"test","path":"/content/root/children/2/type","value":"heading"}.
18
23
 
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.
20
-
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.`;
26
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
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
+ const POINTER = z.string().regex(JSON_POINTER_PATTERN);
26
+ const PATCHES_LIMIT = 500;
27
27
  /**
28
- * Whether the intended value survived the write. The saved document is
29
- * allowed to carry more than was sent: Payload assigns fresh row ids and
30
- * backfills defaults and nulls on save, so `id` keys are ignored and only
31
- * the keys the client sent are compared. Null and absent count as equal.
32
- */ const survives = (expected, actual) => {
28
+ * Discriminated on `op`, so an operation carries only its own members.
29
+ */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
30
+ z.strictObject({
31
+ op: z.literal("add"),
32
+ path: POINTER,
33
+ value: z.unknown()
34
+ }),
35
+ z.strictObject({
36
+ op: z.literal("remove"),
37
+ path: POINTER
38
+ }),
39
+ z.strictObject({
40
+ op: z.literal("replace"),
41
+ path: POINTER,
42
+ value: z.unknown()
43
+ }),
44
+ z.strictObject({
45
+ from: POINTER,
46
+ op: z.literal("move"),
47
+ path: POINTER
48
+ }),
49
+ z.strictObject({
50
+ from: POINTER,
51
+ op: z.literal("copy"),
52
+ path: POINTER
53
+ }),
54
+ z.strictObject({
55
+ op: z.literal("test"),
56
+ path: POINTER,
57
+ value: z.unknown()
58
+ })
59
+ ]);
60
+ const survives = (expected, actual) => {
33
61
  if (expected === void 0 || expected === null) return actual === void 0 || actual === null;
34
62
  if (Array.isArray(expected)) return Array.isArray(actual) && expected.length === actual.length && expected.every((entry, index) => survives(entry, actual[index]));
35
63
  if (isPlainObject(expected)) return isPlainObject(actual) && Object.entries(expected).every(([key, value]) => key === "id" || survives(value, actual[key]));
36
64
  return isPlainObject(actual) || Array.isArray(actual) ? false : JSON.stringify(expected) === JSON.stringify(actual);
37
65
  };
38
- /**
39
- * Pointers whose intended value did not survive the write. Element pointers
40
- * are skipped: an append pointer (`/-`) does not resolve against the saved
41
- * document.
42
- */ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operation) => {
66
+ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operation) => {
43
67
  if (operation.op !== "add" && operation.op !== "replace" || isElementPointer(operation.path)) return [];
44
68
  const pointer = Pointer.fromJSON(operation.path);
45
69
  const expected = pointer.get(intended);
@@ -48,10 +72,10 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
48
72
  });
49
73
  /**
50
74
  * 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
75
+ * document before writing anything, runs the write in a transaction, then
52
76
  * 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.
77
+ * blocks publishing. The draft guard, not this tool, decides where the write
78
+ * lands.
55
79
  */ const patchDocument = defineMcpxTool({
56
80
  name: "patchDocument",
57
81
  description: DESCRIPTION,
@@ -61,37 +85,33 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
61
85
  idempotentHint: false,
62
86
  openWorldHint: false
63
87
  },
64
- isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
88
+ isEnabled: (scope) => scope.collections.writable.length + scope.globals.writable.length > 0,
65
89
  inputSchema: (scope) => ({
66
- ...targetShape(scope, "write", {
67
- collection: "Collection holding the document.",
68
- global: "Global to patch."
69
- }),
90
+ ...entityShape(scope, "write"),
70
91
  ...idShape(scope, "write"),
71
92
  ...localeShape(scope, {
72
93
  required: true,
73
- description: "Locale the patch applies to. Localized fields write here only."
94
+ description: "Localized fields are written in this locale only."
74
95
  }),
75
- patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
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.")
96
+ patches: z.array(PATCH_OPERATION_SCHEMA).min(1).max(PATCHES_LIMIT),
97
+ expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
77
98
  }),
78
99
  handler: async ({ args, scope }) => {
79
- const target = resolveTarget(scope, args, "write");
80
- const id = requireIdFor(target, args.id);
100
+ const target = resolveDocument(scope, args, "write");
81
101
  const { payload } = scope.req;
82
102
  const locale = localeOf(scope, args.locale);
83
103
  const patches = args.patches;
84
104
  return await withTransaction(scope.req, async () => {
85
- const doc = await readTarget(scope, {
105
+ const doc = await readDraft(scope, {
86
106
  target,
87
- id,
88
107
  locale
89
108
  });
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"] });
109
+ const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again and re-apply the patch.");
110
+ if (stale) return stale;
91
111
  const applied = applyPatchOperations(payload.config, {
92
112
  doc,
93
113
  patches,
94
- ref: refOf(target)
114
+ ref: target
95
115
  });
96
116
  if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
97
117
  const write = {
@@ -105,26 +125,32 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
105
125
  if (target.kind === "collection") await payload.update({
106
126
  ...write,
107
127
  collection: target.slug,
108
- id
128
+ id: target.id
109
129
  });
110
130
  else await payload.updateGlobal({
111
131
  ...write,
112
132
  fallbackLocale: false,
113
133
  slug: target.slug
114
134
  });
115
- const saved = await readTarget(scope, {
135
+ const saved = await readDraft(scope, {
116
136
  target,
117
- id,
118
137
  locale,
119
138
  privileged: true
120
139
  });
121
- const notApplied = notAppliedPointers(patches, applied.next, saved);
140
+ const readable = await readDraft(scope, {
141
+ target,
142
+ locale
143
+ }).catch((error) => {
144
+ if (error instanceof NotFound || error instanceof Forbidden) return;
145
+ throw error;
146
+ });
147
+ const notApplied = readable ? notAppliedPointers(patches, applied.next, readable) : [];
122
148
  const validation = await collectPublishBlockers(scope.req, {
123
149
  doc: saved,
124
150
  entity: target
125
151
  });
126
152
  return jsonResult({
127
- ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
153
+ ...identityOf(target, saved["id"]),
128
154
  status: saved["_status"],
129
155
  updatedAt: saved["updatedAt"],
130
156
  ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
@@ -135,4 +161,4 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
135
161
  }
136
162
  });
137
163
  //#endregion
138
- export { patchDocument };
164
+ export { PATCH_OPERATION_SCHEMA, patchDocument };
@@ -1,48 +1,39 @@
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";
1
+ import { defineMcpxTool } from "../define-tool.mjs";
2
+ import { jsonResult } from "../result.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
- * 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.
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: `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.`,
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.`,
21
14
  annotations: {
22
15
  destructiveHint: true,
23
16
  openWorldHint: false
24
17
  },
25
- isEnabled: (scope) => scope.publishable.length + scope.publishableGlobals.length > 0,
18
+ isEnabled: (scope) => scope.collections.publishable.length + scope.globals.publishable.length > 0,
26
19
  inputSchema: (scope) => ({
27
- ...targetShape(scope, "publish", {
28
- collection: "Collection holding the document.",
29
- global: "Global to publish."
30
- }),
20
+ ...entityShape(scope, "publish"),
31
21
  ...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.")
22
+ ...localeShape(scope, { required: false }),
23
+ expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
33
24
  }),
34
25
  handler: async ({ args, scope }) => {
35
- const target = resolveTarget(scope, args, "publish");
36
- const id = requireIdFor(target, args.id);
26
+ const target = resolveDocument(scope, args, "publish");
37
27
  const { payload } = scope.req;
38
- const locale = localeOf(scope, void 0);
28
+ const locale = localeOf(scope, args.locale);
29
+ const { localization } = scope;
39
30
  return await withTransaction(scope.req, async () => {
40
- const doc = await readTarget(scope, {
31
+ const doc = await readDraft(scope, {
41
32
  target,
42
- id,
43
33
  locale
44
34
  });
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"] });
35
+ const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again before publishing.");
36
+ if (stale) return stale;
46
37
  const write = {
47
38
  data: withPublishIntent({}),
48
39
  depth: 0,
@@ -50,27 +41,30 @@ There is no unpublish: reverting to a draft stays a human action in the admin pa
50
41
  fallbackLocale: false,
51
42
  overrideAccess: false,
52
43
  req: scope.req,
53
- ...locale === void 0 ? {} : { locale }
44
+ ...locale === void 0 ? {} : { locale },
45
+ ...args.locale === void 0 ? {} : { publishSpecificLocale: args.locale }
54
46
  };
55
47
  if (target.kind === "collection") await payload.update({
56
48
  ...write,
57
49
  collection: target.slug,
58
- id
50
+ id: target.id
59
51
  });
60
52
  else await payload.updateGlobal({
61
53
  ...write,
62
54
  slug: target.slug
63
55
  });
64
- const saved = await readTarget(scope, {
56
+ const saved = await readDraft(scope, {
65
57
  target,
66
- id,
67
58
  locale,
68
59
  privileged: true
69
60
  });
61
+ const others = await collectLocaleBlockers(scope, target, localization?.locales.filter((entry) => entry !== locale) ?? []);
70
62
  return jsonResult({
71
- ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
63
+ ...identityOf(target, saved["id"]),
72
64
  status: saved["_status"],
73
- updatedAt: saved["updatedAt"]
65
+ updatedAt: saved["updatedAt"],
66
+ ...others.blockers.length > 0 ? { otherLocaleBlockers: others.blockers } : {},
67
+ ...others.unavailable ? { otherLocaleBlockersUnavailable: true } : {}
74
68
  });
75
69
  });
76
70
  }