@abinnovision/payloadcms-mcpx 1.0.0-beta.8 → 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 (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +331 -194
  3. package/dist/api-keys/collection.mjs +3 -3
  4. package/dist/api-keys/fields.mjs +56 -5
  5. package/dist/api-keys/setup-guide.mjs +56 -0
  6. package/dist/auth/resolve.mjs +5 -7
  7. package/dist/capabilities.mjs +23 -4
  8. package/dist/client/index.d.mts +2 -0
  9. package/dist/client/index.mjs +2 -0
  10. package/dist/client/setup-guide.d.mts +14 -0
  11. package/dist/client/setup-guide.mjs +87 -0
  12. package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
  13. package/dist/endpoint/handler.mjs +11 -5
  14. package/dist/endpoint/index.mjs +4 -0
  15. package/dist/endpoint/server.mjs +18 -26
  16. package/dist/i18n.mjs +4 -15
  17. package/dist/index.d.mts +4 -4
  18. package/dist/index.mjs +4 -3
  19. package/dist/options.mjs +31 -26
  20. package/dist/plugin.mjs +1 -0
  21. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  22. package/dist/request.mjs +8 -0
  23. package/dist/result.d.mts +11 -0
  24. package/dist/result.mjs +20 -0
  25. package/dist/schema/describe.mjs +3 -15
  26. package/dist/schema/index.mjs +8 -0
  27. package/dist/schema/lexical-pointer.mjs +125 -0
  28. package/dist/schema/lexical.mjs +195 -27
  29. package/dist/schema/outline.mjs +67 -0
  30. package/dist/schema/pointer.mjs +77 -30
  31. package/dist/schema/shape.mjs +133 -51
  32. package/dist/schema/walk.mjs +44 -64
  33. package/dist/tools/{index.mjs → builtin.mjs} +8 -5
  34. package/dist/tools/create-document.mjs +34 -15
  35. package/dist/tools/describe-schema.mjs +21 -7
  36. package/dist/tools/find-documents.mjs +13 -6
  37. package/dist/tools/get-document.mjs +45 -11
  38. package/dist/tools/list-capabilities.mjs +19 -9
  39. package/dist/tools/names.mjs +2 -1
  40. package/dist/tools/patch-document.mjs +32 -21
  41. package/dist/tools/publish-document.mjs +79 -0
  42. package/dist/tools/shared.mjs +84 -32
  43. package/dist/tools/target.mjs +7 -11
  44. package/dist/tools/validate-document.mjs +20 -12
  45. package/dist/types.d.mts +115 -42
  46. package/dist/types.mjs +3 -4
  47. package/dist/version.mjs +1 -1
  48. package/dist/write/draft-guard.mjs +47 -44
  49. package/dist/write/patch.mjs +174 -92
  50. package/dist/write/publish-blockers.mjs +13 -12
  51. package/dist/write/publish-intent.mjs +17 -0
  52. package/dist/write/transaction.mjs +8 -3
  53. package/package.json +24 -9
  54. package/dist/i18n.d.mts +0 -1
  55. package/dist/options.d.mts +0 -2
  56. package/dist/schema/lexical.d.mts +0 -1
  57. package/dist/schema/walk.d.mts +0 -3
  58. package/dist/tools/target.d.mts +0 -3
  59. package/dist/tools/types.d.mts +0 -5
  60. package/dist/write/publish-blockers.d.mts +0 -15
@@ -4,12 +4,14 @@ import { findDocuments } from "./find-documents.mjs";
4
4
  import { getDocument } from "./get-document.mjs";
5
5
  import { listCapabilities } from "./list-capabilities.mjs";
6
6
  import { patchDocument } from "./patch-document.mjs";
7
+ import { publishDocument } from "./publish-document.mjs";
7
8
  import { validateDocument } from "./validate-document.mjs";
8
- //#region src/tools/index.ts
9
+ //#region src/tools/builtin.ts
9
10
  /**
10
- * The builtin tools in registration order. The surface is fixed: adding a
11
- * collection, block or field never changes it. Typed over `never` because
12
- * each tool validates its own arguments through its input schema.
11
+ * The builtin tools, in registration order. Fixed: adding a collection, block
12
+ * or field never changes the surface. They differ from a custom tool only in
13
+ * `isEnabled`, which derives from the key's capabilities rather than a
14
+ * checkbox of their own.
13
15
  */ const BUILTIN_TOOLS = [
14
16
  listCapabilities,
15
17
  describeSchema,
@@ -17,7 +19,8 @@ import { validateDocument } from "./validate-document.mjs";
17
19
  getDocument,
18
20
  patchDocument,
19
21
  createDocument,
20
- validateDocument
22
+ validateDocument,
23
+ publishDocument
21
24
  ];
22
25
  //#endregion
23
26
  export { BUILTIN_TOOLS };
@@ -1,45 +1,63 @@
1
- import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
3
- import { resolveTarget } from "./target.mjs";
1
+ import { errorResult, jsonResult } from "../result.mjs";
4
2
  import { validateWriteValue } from "../schema/shape.mjs";
3
+ import "../schema/index.mjs";
4
+ import { draftSentence, localeOf, localeShape, patchOnlySlugs, readTarget, slugEnum, slugsFor } from "./shared.mjs";
5
+ import { resolveTarget } from "./target.mjs";
6
+ import { defineMcpxTool } from "../types.mjs";
5
7
  import { stripRowIds } from "../write/patch.mjs";
6
8
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
7
9
  import { z } from "zod";
8
10
  //#region src/tools/create-document.ts
9
- const createDocument = {
11
+ /** Names the writable slugs this tool leaves out, so the gap reads as intent. */ const uploadSentence = (scope) => {
12
+ const slugs = patchOnlySlugs(scope);
13
+ return slugs.length === 0 ? "" : `\n\nLeft out of "collection" on purpose: ${slugs.join(", ")}. Those documents are files, and no tool here carries one. Upload the file in the admin panel, then edit its fields with patchDocument.`;
14
+ };
15
+ const DESCRIPTION = (scope) => `Creates a new document from a minimal seed. Only the fields describeSchema lists may appear in "data"; unknown keys are refused with the valid siblings, and "id" is Payload's to assign. The document may be incomplete: the response lists "publishBlockers", which patchDocument can then work through, and "publishBlockersUnavailable" when that check itself failed. Use this when no document exists yet; prefer patching an existing draft otherwise.
16
+
17
+ ${draftSentence(scope)}${uploadSentence(scope)}`;
18
+ /**
19
+ * Collection-only, because a global always exists, and never reaches an upload
20
+ * collection, because a create there would have to carry the file.
21
+ *
22
+ * The seed is checked against the collection's fields before the create, so an
23
+ * unknown key is refused with its valid siblings rather than dropped. Row ids
24
+ * in the seed are stripped and a top-level `id` is refused outright. The new
25
+ * document is re-read privileged afterwards to collect publish blockers, which
26
+ * is why an incomplete seed still succeeds and comes back with a checklist.
27
+ */ const createDocument = defineMcpxTool({
10
28
  name: "createDocument",
11
- description: `Creates a new document as a draft from a minimal seed. Only the fields describeSchema lists may appear in "data"; unknown keys are refused with the valid siblings. The draft may be incomplete: the response lists "publishBlockers", which patchDocument can then work through. Use this when no document exists yet; prefer patching an existing draft otherwise.`,
29
+ description: DESCRIPTION,
12
30
  annotations: {
13
31
  readOnlyHint: false,
14
32
  destructiveHint: false,
15
33
  idempotentHint: false,
16
34
  openWorldHint: false
17
35
  },
18
- isEnabled: (scope) => scope.writable.length > 0,
36
+ isEnabled: (scope) => slugsFor(scope, "create").collections.length > 0,
19
37
  inputSchema: (scope) => ({
20
- collection: slugEnum(scope.writable).describe("Collection to create the document in."),
38
+ collection: slugEnum(slugsFor(scope, "create").collections).describe("Collection to create the document in."),
21
39
  ...localeShape(scope, {
22
40
  required: true,
23
41
  description: "Locale the localized fields of the seed belong to."
24
42
  }),
25
43
  data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
26
44
  }),
27
- handler: async (args, scope) => {
28
- const target = resolveTarget(scope, { collection: args.collection }, "write");
45
+ handler: async ({ args, scope }) => {
46
+ const target = resolveTarget(scope, { collection: args.collection }, "create");
29
47
  const { payload } = scope.req;
30
48
  const locale = localeOf(scope, args.locale);
31
- const { id: _ignored, ...seed } = args.data;
49
+ if ("id" in args.data) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
32
50
  const problems = validateWriteValue(payload.config, {
33
51
  pointer: "",
34
52
  resolution: {
35
53
  fields: target.config.flattenedFields,
36
54
  prefix: []
37
55
  }
38
- }, seed);
56
+ }, args.data);
39
57
  if (problems.length > 0) return errorResult("Nothing was created.", { problems });
40
58
  const created = await payload.create({
41
59
  collection: args.collection,
42
- data: stripRowIds(seed),
60
+ data: stripRowIds(args.data),
43
61
  depth: 0,
44
62
  draft: true,
45
63
  overrideAccess: false,
@@ -52,7 +70,7 @@ const createDocument = {
52
70
  locale,
53
71
  privileged: true
54
72
  });
55
- const publishBlockers = await collectPublishBlockers(scope.req, {
73
+ const validation = await collectPublishBlockers(scope.req, {
56
74
  doc: saved,
57
75
  entity: target
58
76
  });
@@ -60,9 +78,10 @@ const createDocument = {
60
78
  id: saved["id"],
61
79
  status: saved["_status"],
62
80
  updatedAt: saved["updatedAt"],
63
- ...publishBlockers.length > 0 ? { publishBlockers } : {}
81
+ ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
82
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {}
64
83
  });
65
84
  }
66
- };
85
+ });
67
86
  //#endregion
68
87
  export { createDocument };
@@ -1,11 +1,19 @@
1
+ import { jsonResult } from "../result.mjs";
2
+ import { nodePropertiesFor } from "../schema/lexical.mjs";
1
3
  import { translatorFor } from "../i18n.mjs";
2
- import { jsonResult } from "../endpoint/result.mjs";
4
+ import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
5
+ import "../schema/index.mjs";
3
6
  import { targetShape } from "./shared.mjs";
4
7
  import { refOf, resolveTarget } from "./target.mjs";
5
- import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
8
+ import { defineMcpxTool } from "../types.mjs";
6
9
  import { z } from "zod";
7
- //#region src/tools/describe-schema.ts
8
- const describeSchema = {
10
+ /**
11
+ * Describes each requested path independently and returns a per
12
+ * path error object instead of failing the call, so a client exploring several
13
+ * branches at once keeps the nodes that did resolve. `expand` swaps the
14
+ * requested paths for every node reachable from the root and appends a
15
+ * truncation notice past {@link REACHABLE_PATHS_LIMIT}.
16
+ */ const describeSchema = defineMcpxTool({
9
17
  name: "describeSchema",
10
18
  description: `Describes the writable shape of a document, one node at a time.
11
19
 
@@ -15,7 +23,11 @@ Call it with no "paths" to get a collection's own fields. Every "blocks" field s
15
23
 
16
24
  A "richText" field stops there too. It lists the Lexical node types it accepts in "nodes", and "next" carries a path for every node type that holds fields of its own: "/content/link" for a link node, "/content/block/callout" and "/content/inlineBlock/badge" for the block nodes. Descend to get the real field list instead of guessing what a node carries. Upload nodes are not addressable, because their fields depend on the collection the node points at.
17
25
 
18
- Paths here use the same JSON Pointer syntax as getDocument and patchDocument, and are already resolved through anything that does not nest in the stored document. The difference is only what stands in an element position: a path names an array element "*" and a block by its slug, where a pointer into a document carries a 0-based index. So "/items/*/title" is written at "/items/0/title", and "/layout/sections/hero" at "/layout/sections/0".
26
+ Write each Lexical node the way Lexical serializes it, with every property its type carries rather than a trimmed subset, and with the value Lexical would have written there. Those requirements are stated rather than left to be discovered: the response carries one final "nodeProperties" entry keyed by node type, naming each property and what belongs there in the same words a refused write uses, so a node can be built from this response alone. A field's own "nodes" says which of those types it accepts. The root takes exactly "children", "direction", "format", "indent", "type" and "version" and refuses anything else. The admin editor rehydrates nodes through their classes, so a list item whose "indent" is missing, null or a string is stored and then throws on open, and a heading whose "tag" is a number is stored untagged. A write naming a property means exactly that. A state whose root holds no children is refused however it is written, because Lexical reads it as empty and throws; clear a field with null instead.
27
+
28
+ A rich text field's value is addressable too, so an edit does not have to rewrite the whole state: "/content/root/children/0" is the first top-level node, "/content/root/children/0/children/1" a node inside it, "/content/root/children/0/tag" one property of a node, and "/content/root/children/0/fields/url" a field of a node, described at the "next" path for that node type. Append a node with "/-". Which node sits at an index is only knowable from what is stored, so read it first: getDocument with "outline" answers with the pointer, type, "version" and a text excerpt for every node, which is far cheaper than reading the whole state.
29
+
30
+ Paths here use the same JSON Pointer syntax as getDocument and patchDocument, and are already resolved through anything that does not nest in the stored document. The difference is only what stands in an element position: a path names an array element "*" and a block by its slug, where a pointer into a document carries a 0-based index. So "/items/*/title" is written at "/items/0/title", and "/layout/sections/hero" at "/layout/sections/0". 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".
19
31
 
20
32
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
21
33
  annotations: {
@@ -31,7 +43,7 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
31
43
  paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"/layout/sections/sectionWrapper\". Omit for the collection root."),
32
44
  expand: z.boolean().optional().describe("Return every node reachable from the root in one response. Ignores paths.")
33
45
  }),
34
- handler: (args, scope) => {
46
+ handler: ({ args, scope }) => {
35
47
  const ref = refOf(resolveTarget(scope, args, "read"));
36
48
  const { config } = scope.req.payload;
37
49
  const describeNode = nodeDescriber(translatorFor(scope.req.i18n));
@@ -46,9 +58,11 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
46
58
  };
47
59
  }
48
60
  });
61
+ const nodeTypes = nodes.flatMap((node) => (node.fields ?? []).flatMap((field) => field.nodes ?? []));
62
+ if (nodeTypes.length > 0) nodes.push({ nodeProperties: nodePropertiesFor(nodeTypes) });
49
63
  if (expanded?.truncated) nodes.push({ error: `Result truncated after ${String(400)} nodes. Request explicit paths instead.` });
50
64
  return Promise.resolve(jsonResult(nodes));
51
65
  }
52
- };
66
+ });
53
67
  //#endregion
54
68
  export { describeSchema };
@@ -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 };