@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,26 +1,27 @@
1
- import { lexicalSubSchema } from "./lexical.mjs";
2
- import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
1
+ import { ROOT_PROPERTIES, constrainsFields, lexicalSubSchema, nodeProblems, propertyProblem, rootProblems } from "./lexical.mjs";
2
+ import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, isPlainObject, splitPath } from "./walk.mjs";
3
3
  //#region src/schema/shape.ts
4
- /**
5
- * Keys Payload manages on a row that a client may echo back harmlessly.
6
- */ const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
4
+ const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
7
5
  "blockName",
8
6
  "blockType",
9
7
  "id"
10
8
  ]);
11
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12
9
  /**
13
- * Checks the fields a single Lexical node carries against the schema its
14
- * feature declares for that node type.
15
- *
16
- * A node with nothing to declare, and one whose sub-fields cannot be named at
17
- * a position, are both left alone.
10
+ * Lexical refuses to hydrate a state whose root holds nothing: `isEmpty` is a
11
+ * node map of one, and the editor throws on it rather than rendering nothing.
12
+ * An empty field is stored as null instead, so this is only ever reached by
13
+ * emptying one that was there.
14
+ */ const EMPTY_ROOT = "an editor state needs at least one node. Clear the field with null instead.";
15
+ const quoted = (properties) => properties.map((property) => `"${property}"`).join(", ");
16
+ /**
17
+ * A node with nothing to declare, and one whose sub-fields cannot be named at a
18
+ * position, are both left alone.
18
19
  */ const checkNodeFields = (scope, field, node) => {
19
20
  const sub = lexicalSubSchema(field, node.type);
20
21
  if (!sub) return;
21
22
  const data = node.fields;
22
23
  if (!isPlainObject(data)) {
23
- scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
24
+ if (!constrainsFields(node.type)) scope.problems.push(`${scope.pointer}: a "${node.type}" node carries a "fields" object.`);
24
25
  return;
25
26
  }
26
27
  const nested = {
@@ -47,48 +48,126 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
47
48
  }, data);
48
49
  };
49
50
  /**
50
- * Checks an editor state against what the field's editor can actually
51
- * produce: every node type, and the fields each node carries.
51
+ * An absent property is already reported as missing. Anything else present is
52
+ * checked, not just a string: Lexical stores a heading tag of 3 as readily as
53
+ * one of "h3".
54
+ */ const checkNarrowedProperty = (scope, at, value) => {
55
+ if (value !== void 0 && !(typeof value === "string" && at.values.includes(value))) scope.problems.push(`${at.pointer}: ${JSON.stringify(value)} is not available for a "${at.type}" node in this field's editor. Allowed: ${at.values.join(", ")}`);
56
+ };
57
+ /**
58
+ * One node, wherever it came from. `pointer` addresses the node itself, so a
59
+ * node written at a position and a node written inside a whole editor state
60
+ * are held to the same rules and report them the same way.
52
61
  *
53
- * Payload does not: the Lexical validator runs node validations only for the
54
- * few node types that register one, so a `heading` inside a field whose
55
- * editor has no heading feature is stored without complaint and only fails
56
- * later, at render or when the document is reopened in the admin editor. A key
57
- * a node's fields do not declare is dropped just as silently. The same holds
58
- * one level down, for the node properties a feature narrows: an `h3` in an
59
- * editor restricted to `h4` is stored as readily as an `h4`.
60
- */ const checkRichText = (scope, editor, value) => {
62
+ * Payload does not check any of this: the Lexical validator runs node
63
+ * validations only for the few node types that register one, so a `heading`
64
+ * inside a field whose editor has no heading feature is stored without
65
+ * complaint and only fails later, at render or when the document is reopened
66
+ * in the admin editor. A key a node's fields do not declare is dropped just as
67
+ * silently. The same holds one level down, for the node properties a feature
68
+ * narrows: an `h3` in an editor restricted to `h4` is stored as readily as an
69
+ * `h4`, and a node written without the properties its class hydrates from is
70
+ * stored and then throws when the editor opens it.
71
+ */ const checkNode = (scope, editor, node, pointer) => {
72
+ if (!isPlainObject(node) || typeof node["type"] !== "string") {
73
+ scope.problems.push(`${pointer}: every node needs a "type".`);
74
+ return;
75
+ }
76
+ const type = node["type"];
77
+ if (!editor.allowed.includes(type)) {
78
+ scope.problems.push(`${pointer}: "${type}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
79
+ return;
80
+ }
81
+ const problems = nodeProblems(node);
82
+ if (problems.missing.length > 0) scope.problems.push(`${pointer}: a "${type}" node is missing ${quoted(problems.missing)}. Write nodes as Lexical serializes them.`);
83
+ for (const problem of problems.rejected) scope.problems.push(`${pointer}/${problem.property}: a "${type}" node needs ${problem.needs} here.`);
84
+ for (const [property, values] of Object.entries(editor.nodeOptions?.[type] ?? {})) checkNarrowedProperty(scope, {
85
+ pointer: `${pointer}/${property}`,
86
+ type,
87
+ values
88
+ }, node[property]);
89
+ if (editor.field) checkNodeFields({
90
+ ...scope,
91
+ pointer
92
+ }, editor.field, {
93
+ fields: node["fields"],
94
+ type
95
+ });
96
+ checkNodes(scope, editor, node["children"], `${pointer}/children`);
97
+ };
98
+ /** `pointer` addresses the list. A node holding none is left alone. */ const checkNodes = (scope, editor, nodes, pointer) => {
99
+ if (!Array.isArray(nodes)) return;
100
+ nodes.forEach((node, index) => {
101
+ checkNode(scope, editor, node, `${pointer}/${String(index)}`);
102
+ });
103
+ };
104
+ const checkRichText = (scope, editor, value) => {
61
105
  if (!isPlainObject(value) || !isPlainObject(value["root"])) {
62
106
  scope.problems.push(`${scope.pointer}: expected a Lexical editor state with a "root".`);
63
107
  return;
64
108
  }
65
- const walk = (nodes, pointer) => {
66
- if (!Array.isArray(nodes)) return;
67
- nodes.forEach((node, index) => {
68
- const at = `${pointer}/${String(index)}`;
69
- if (!isPlainObject(node) || typeof node["type"] !== "string") {
70
- scope.problems.push(`${at}: every node needs a "type".`);
71
- return;
72
- }
73
- if (!editor.allowed.includes(node["type"])) {
74
- scope.problems.push(`${at}: "${node["type"]}" is not available in this field's editor. Allowed: ${editor.allowed.join(", ")}`);
75
- return;
76
- }
77
- for (const [property, values] of Object.entries(editor.nodeOptions?.[node["type"]] ?? {})) {
78
- const value = node[property];
79
- if (typeof value === "string" && !values.includes(value)) scope.problems.push(`${at}/${property}: "${value}" is not available for a "${node["type"]}" node in this field's editor. Allowed: ${values.join(", ")}`);
80
- }
81
- if (editor.field) checkNodeFields({
82
- ...scope,
83
- pointer: at
84
- }, editor.field, {
85
- fields: node["fields"],
86
- type: node["type"]
87
- });
88
- walk(node["children"], `${at}/children`);
89
- });
109
+ const root = value["root"];
110
+ const { missing, rejected, unexpected } = rootProblems(root);
111
+ if (missing.length > 0) scope.problems.push(`${scope.pointer}/root: the root node is missing ${quoted(missing)}. Write nodes as Lexical serializes them.`);
112
+ for (const problem of rejected) scope.problems.push(`${scope.pointer}/root/${problem.property}: the root node needs ${problem.needs} here.`);
113
+ for (const property of unexpected) scope.problems.push(`${scope.pointer}/root/${property}: no such property on the root node. Available: ${Object.keys(ROOT_PROPERTIES).join(", ")}`);
114
+ if (Array.isArray(root["children"]) && root["children"].length === 0) scope.problems.push(`${scope.pointer}/root/children: ${EMPTY_ROOT}`);
115
+ checkNodes(scope, editor, root["children"], `${scope.pointer}/root/children`);
116
+ };
117
+ /**
118
+ * `type` decides how every other property on a node is read, so replacing it
119
+ * alone would leave a heading shaped like a text node. Everything else is held
120
+ * to the constraint the node walk holds it to and nothing more, since a
121
+ * feature may put any property on a node.
122
+ */ const checkNodeProperty = (scope, editor, position, value) => {
123
+ const { nodeType: type, property } = position;
124
+ if (property === "type") {
125
+ scope.problems.push(`${scope.pointer}: a node's "type" cannot be replaced on its own. Replace the whole node.`);
126
+ return;
127
+ }
128
+ if (position.isRoot && !(property in ROOT_PROPERTIES)) {
129
+ scope.problems.push(`${scope.pointer}: no such property on the root node. Available: ${Object.keys(ROOT_PROPERTIES).join(", ")}`);
130
+ return;
131
+ }
132
+ const problem = propertyProblem(type, property, value);
133
+ if (problem) scope.problems.push(`${scope.pointer}: a "${type}" node needs ${problem.needs} here.`);
134
+ const values = editor.nodeOptions?.[type]?.[property];
135
+ if (values) checkNarrowedProperty(scope, {
136
+ pointer: scope.pointer,
137
+ type,
138
+ values
139
+ }, value);
140
+ };
141
+ /**
142
+ * A value written at a position inside an editor state rather than as the
143
+ * whole state.
144
+ */ const checkLexicalWrite = (scope, position, value) => {
145
+ const editor = {
146
+ allowed: position.descriptor.nodes ?? [],
147
+ field: position.field,
148
+ nodeOptions: position.descriptor.nodeOptions
90
149
  };
91
- walk(value["root"]["children"], `${scope.pointer}/root/children`);
150
+ if (position.kind === "property") {
151
+ checkNodeProperty(scope, editor, position, value);
152
+ return;
153
+ }
154
+ if (position.kind === "nodes") {
155
+ if (!Array.isArray(value)) {
156
+ scope.problems.push(`${scope.pointer}: expected an array of nodes.`);
157
+ return;
158
+ }
159
+ if (position.isRoot && value.length === 0) {
160
+ scope.problems.push(`${scope.pointer}: ${EMPTY_ROOT}`);
161
+ return;
162
+ }
163
+ checkNodes(scope, editor, value, scope.pointer);
164
+ return;
165
+ }
166
+ if (position.isRoot) {
167
+ scope.problems.push(`${scope.pointer}: the root of an editor state cannot be replaced on its own. Write the whole field instead.`);
168
+ return;
169
+ }
170
+ checkNode(scope, editor, value, scope.pointer);
92
171
  };
93
172
  const checkLeafValue = (scope, descriptor, value) => {
94
173
  if (descriptor.readOnly) {
@@ -126,8 +205,7 @@ const checkLeafValue = (scope, descriptor, value) => {
126
205
  });
127
206
  };
128
207
  /**
129
- * Walks an incoming value against the schema, reporting every shape problem
130
- * rather than the first.
208
+ * Reports every shape problem rather than the first.
131
209
  *
132
210
  * Shape only: unknown field names, unknown block slugs, read-only fields, and
133
211
  * rich text nodes or node properties the field's editor cannot produce.
@@ -197,6 +275,10 @@ const checkLeafValue = (scope, descriptor, value) => {
197
275
  prefix: target.resolution.prefix,
198
276
  problems
199
277
  };
278
+ if (target.resolution.lexical) {
279
+ checkLexicalWrite(scope, target.resolution.lexical, value);
280
+ return problems;
281
+ }
200
282
  if (target.resolution.descriptor) {
201
283
  checkLeafValue(scope, target.resolution.descriptor, value);
202
284
  return problems;
@@ -205,4 +287,4 @@ const checkLeafValue = (scope, descriptor, value) => {
205
287
  return problems;
206
288
  };
207
289
  //#endregion
208
- export { validateWriteValue };
290
+ export { EMPTY_ROOT, validateWriteValue };
@@ -2,50 +2,39 @@ import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
2
2
  import { translateAny } from "../i18n.mjs";
3
3
  import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
4
4
  //#region src/schema/walk.ts
5
- /**
6
- * Fields Payload maintains, which a client may neither address nor supply.
7
- */ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
5
+ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
8
6
  "_status",
9
7
  "createdAt",
10
8
  "deletedAt",
11
9
  "id",
12
10
  "updatedAt"
13
11
  ]);
12
+ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
13
+ /** No segments is the root pointer, `""`. */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
14
+ /** Unescapes `~1` and `~0`. The root pointer yields no segments. */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
15
+ /** `-` included, since RFC 6901 reads it as the position after the last. */ const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
16
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
14
17
  /**
15
- * Shape a JSON Pointer must have to be parseable at all.
16
- */ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
17
- /**
18
- * Joins segments into a JSON Pointer, so the segments `items`, `*`, `title`
19
- * read as one path to a subfield of every element of `items`. No segments is
20
- * the root pointer, `""`.
21
- */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
22
- /**
23
- * Splits a JSON Pointer into its segments, unescaping `~1` and `~0`. The root
24
- * pointer yields no segments.
25
- */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
26
- /**
27
- * Restates a path Payload reports on a validation error (`layout.0.title`) as
28
- * a JSON Pointer, so everything this plugin hands back addresses documents the
29
- * same way. Payload's path already carries real indices, so it maps directly.
18
+ * A path Payload reports on a validation error (`layout.0.title`) as a JSON
19
+ * Pointer, so everything handed back addresses documents the same way. The
20
+ * path already carries real indices, so it maps directly.
30
21
  */ const pointerFromPayloadPath = (path) => path ? joinPath(path.split(".")) : "";
22
+ /** On a flattened field, whichever of `blockReferences` and `blocks` was declared. */ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
31
23
  /**
32
- * Blocks a blocks field accepts, by slug. On a flattened field, whichever of
33
- * `blockReferences` and `blocks` was declared carries the definitions.
34
- */ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
35
- /**
36
- * Resolves one of a blocks field's slugs to its definition.
37
- *
38
- * A definition inlined on the field wins over the shared registry. A block's
39
- * own fields are identical wherever it appears, but the blocks its children
40
- * accept are not, so an inline definition has to be read at its position.
41
- * The registry (`config.blocks`) is the fallback for slugs referenced by name.
24
+ * An inlined definition wins over the registry (`config.blocks`). A block's own
25
+ * fields are identical wherever it appears, but the blocks its children accept
26
+ * are not, so an inline definition has to be read at its position.
42
27
  */ const blockOf = (config, field, slug) => {
43
28
  const declared = field.blockReferences ?? field.blocks;
44
29
  const inline = declared.find((block) => typeof block !== "string" && block.slug === slug);
45
30
  if (inline) return inline;
46
31
  return declared.includes(slug) ? config.blocks?.find((block) => block.slug === slug) : void 0;
47
32
  };
48
- const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || fieldIsHiddenOrDisabled(field);
33
+ /**
34
+ * Payload's `fieldIsHiddenOrDisabled` reads `hidden` and `admin.disabled`, not
35
+ * `admin.hidden`, which is what its own upload base fields carry.
36
+ */ const isAdminHidden = (field) => "admin" in field && field.admin.hidden === true;
37
+ /** A field kept out of the admin panel is kept out of the MCP surface too. */ const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || isAdminHidden(field) || fieldIsHiddenOrDisabled(field);
49
38
  const isReadOnly = (field) => "admin" in field && field.admin.readOnly === true;
50
39
  const describeBase = (field, { path, readOnly, translate }) => {
51
40
  const description = translate("admin" in field ? field.admin.description : void 0);
@@ -80,11 +69,9 @@ const withRows = (descriptor, field) => ({
80
69
  ...field.maxRows === void 0 ? {} : { maxRows: field.maxRows }
81
70
  });
82
71
  /**
83
- * Whether a descriptor stands for a construct that only holds other fields.
84
- *
85
- * These describe a position rather than a value, so everything that resolves a
86
- * path to something writable skips them; only {@link describeNode} reports
87
- * them, to carry what the container itself declares.
72
+ * A container describes a position rather than a value, so everything resolving
73
+ * a path to something writable skips it. Only {@link nodeDescriber} reports one,
74
+ * to carry what the container itself declares.
88
75
  */ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
89
76
  /**
90
77
  * Whether a container declares anything a client could not infer from the
@@ -93,20 +80,16 @@ const withRows = (descriptor, field) => ({
93
80
  /**
94
81
  * Flattens a field list into descriptors addressed relative to the node.
95
82
  *
96
- * The input is Payload's own flattened shape, which has already merged every
97
- * construct that exists only in the admin UI (unnamed tabs, unnamed groups,
98
- * `row`, `collapsible`) and dropped `ui` fields. Named tabs, groups and
99
- * arrays contribute a path segment, and are described in their own right when
100
- * they declare something of their own: an array always, since its row counts
101
- * live nowhere else, a group or tab only when it carries a description or a
102
- * constraint. The walk stops at every blocks field and names the slugs instead
103
- * of descending, which keeps a node proportional to the number of blocks it
104
- * allows rather than to the size of their definitions.
83
+ * The input is Payload's own flattened shape, so the admin-only constructs
84
+ * (unnamed tabs and groups, `row`, `collapsible`, `ui`) are already gone. Named
85
+ * tabs, groups and arrays contribute a path segment, and are described in their
86
+ * own right when they declare something of their own: an array always, since
87
+ * its row counts live nowhere else, a group or tab only when it carries a
88
+ * description or a constraint. The walk stops at every blocks field and names
89
+ * the slugs, which keeps a node proportional to the number of blocks it allows
90
+ * rather than to the size of their definitions.
105
91
  *
106
- * `translate` resolves each `admin.description` to the request's language.
107
- * Callers that walk for paths alone leave it out and get the language-agnostic
108
- * default, so a missing argument costs language selection, never the
109
- * description itself.
92
+ * Omitting `translate` costs language selection, never the description itself.
110
93
  */ const describeFields = (fields, translate = translateAny) => {
111
94
  const walk = (current, prefix, parentReadOnly) => current.flatMap((field) => {
112
95
  if (isSkipped(field)) return [];
@@ -133,33 +116,30 @@ const withRows = (descriptor, field) => ({
133
116
  /**
134
117
  * The descriptors that address a value, which is what every walk resolving a
135
118
  * path against a document needs. A container describes a position rather than
136
- * a value, so only {@link describeNode} reports one.
119
+ * a value, so only {@link nodeDescriber} reports one.
137
120
  */ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
138
- /**
139
- * Locates the blocks field that a resolved descriptor path refers to.
140
- */ const findBlocksField = (fields, path) => {
121
+ const findFieldAt = (fields, path, type) => {
141
122
  for (const field of fields) {
142
123
  if (!("name" in field) || field.name !== path[0]) continue;
143
- if (field.type === "blocks" && path.length === 1) return field;
144
- if (field.type === "tab" || field.type === "group") return findBlocksField(field.flattenedFields, path.slice(1));
145
- if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
124
+ if (field.type === type && path.length === 1) return field;
125
+ if (field.type === "tab" || field.type === "group") return findFieldAt(field.flattenedFields, path.slice(1), type);
126
+ if (field.type === "array" && path[1] === "*") return findFieldAt(field.flattenedFields, path.slice(2), type);
146
127
  }
147
128
  };
129
+ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
148
130
  /**
149
131
  * Locates the rich text field that a resolved descriptor path refers to, so
150
132
  * its editor can be introspected for the fields its nodes carry.
151
- */ const findRichTextField = (fields, path) => {
152
- for (const field of fields) {
153
- if (!("name" in field) || field.name !== path[0]) continue;
154
- if (field.type === "richText" && path.length === 1) return field;
155
- if (field.type === "tab" || field.type === "group") return findRichTextField(field.flattenedFields, path.slice(1));
156
- if (field.type === "array" && path[1] === "*") return findRichTextField(field.flattenedFields, path.slice(2));
157
- }
158
- };
159
- const targetOf = (config, ref) => {
133
+ */ const findRichTextField = (fields, path) => findFieldAt(fields, path, "richText");
134
+ /**
135
+ * Looks up the sanitized config for a collection or global, as a
136
+ * {@link SchemaTarget}. Throws on an unknown slug rather than returning
137
+ * undefined, because a reference reaching here has already been checked against
138
+ * the key's capabilities and a miss means the config changed underneath it.
139
+ */ const targetOf = (config, ref) => {
160
140
  const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
161
141
  if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
162
142
  return found;
163
143
  };
164
144
  //#endregion
165
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf };
145
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf };
@@ -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 };