@abinnovision/payloadcms-mcpx 1.0.0-beta.16 → 1.0.0-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -228,6 +228,30 @@ Rules the tools enforce and explain in their own descriptions:
228
228
  when the document is opened, and a heading whose `tag` is `3` comes back
229
229
  untagged, none of which Payload notices on write. A write breaking either is
230
230
  refused, and the message names the property and what belongs there.
231
+ - Those requirements are also published, so a node can be built without being
232
+ corrected into shape first. A `describeSchema` response that reached a
233
+ `richText` field ends with a `nodeProperties` entry stating what each node
234
+ type has to carry, in the same words the refusal uses:
235
+
236
+ ```json
237
+ {
238
+ "text": {
239
+ "detail": "a number",
240
+ "format": "a number",
241
+ "mode": "a string",
242
+ "style": "a string",
243
+ "text": "a string",
244
+ "type": "a string",
245
+ "version": "a number"
246
+ }
247
+ }
248
+ ```
249
+
250
+ It is keyed by node type and stated once for the whole response, because what
251
+ a node carries does not vary by where it is written; the field's own `nodes`
252
+ says which of those types it accepts. The listing is read off the same tables
253
+ the validator checks against, so the two cannot drift apart.
254
+
231
255
  - A rich text field's value is addressable, so a small edit does not have to
232
256
  rewrite the whole state. `/content/root/children/2` is a node,
233
257
  `/content/root/children/2/tag` one of its properties, and
@@ -266,6 +290,14 @@ Rules the tools enforce and explain in their own descriptions:
266
290
  its slug, where a pointer carries a 0-based index. So `/items/*/title` is
267
291
  written at `/items/0/title`, and `/layout/sections/hero` at
268
292
  `/layout/sections/0`.
293
+ - Inside a rich text field that substitution does not apply, because an editor
294
+ state is a tree rather than a list per type. A path there names the node type,
295
+ and a block node its slug; a pointer enters the state at `root` and walks
296
+ `children` by an index counted over every child at that level, not over the
297
+ blocks among them, with the node's own fields under `fields`. So the path
298
+ `/content/block/practice-note/variant` is written at the pointer
299
+ `/content/root/children/7/fields/variant`, and only the stored state says
300
+ which index that is. `getDocument` with `outline` answers that.
269
301
  - Adding a block requires `blockType` on the value; append with `/-`.
270
302
  - Clearing is `replace` with `null`; a list is emptied with `[]` and refuses
271
303
  `null`. `remove` is only valid on list elements, because Payload keeps
@@ -1,8 +1,8 @@
1
- import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, propertyProblem, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
1
+ import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
2
2
  import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
3
3
  import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
4
4
  import { resolveLexicalPointer } from "./lexical-pointer.mjs";
5
5
  import { lexicalOutline } from "./outline.mjs";
6
6
  import { resolveDataPointer } from "./pointer.mjs";
7
7
  import { EMPTY_ROOT, validateWriteValue } from "./shape.mjs";
8
- export { EMPTY_ROOT, JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, lexicalOutline, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, pointerFromPayloadPath, propertyProblem, reachableSchemaPaths, resolveDataPointer, resolveLexicalPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
8
+ export { EMPTY_ROOT, JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, lexicalOutline, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, nodePropertiesFor, pointerFromPayloadPath, propertyProblem, reachableSchemaPaths, resolveDataPointer, resolveLexicalPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
@@ -45,7 +45,7 @@ import { blockOf, blockSlugsOf, isIndexSegment, isPlainObject, joinPath } from "
45
45
  };
46
46
  if (!isPlainObject(state) || !isPlainObject(state["root"])) throw new Error(`"${descriptor.path}" holds no editor state yet. Write the whole field once, then address positions inside it.`);
47
47
  const [entry, ...rest] = at.segments;
48
- if (entry !== "root") throw new Error(`"${String(entry)}" is not a position in a rich text field. An editor state is entered at "root", e.g. "${descriptor.path}/root/children/0".`);
48
+ if (entry !== "root") throw new Error(`"${String(entry)}" is not a position in a rich text field. An editor state is entered at "root", e.g. "${descriptor.path}/root/children/0". getDocument with "outline" lists every position this field holds.`);
49
49
  let node = state["root"];
50
50
  let nodeType = "root";
51
51
  let segments = rest;
@@ -72,7 +72,7 @@ import { blockOf, blockSlugsOf, isIndexSegment, isPlainObject, joinPath } from "
72
72
  }
73
73
  };
74
74
  const [index, ...beyond] = remaining;
75
- if (!isIndexSegment(index)) throw new Error(`"${descriptor.path}${joinPath([...walked, "children"])}" is a list; "${index}" is not an index.`);
75
+ if (!isIndexSegment(index)) throw new Error(`"${descriptor.path}${joinPath([...walked, "children"])}" is a list; "${index}" is not an index. getDocument with "outline" reports the pointer of each node in it.`);
76
76
  const children = node?.["children"];
77
77
  const child = Array.isArray(children) && index !== "-" ? children[Number(index)] : void 0;
78
78
  const type = isPlainObject(child) ? child["type"] : addedValue?.type;
@@ -88,7 +88,7 @@ import { blockOf, blockSlugsOf, isIndexSegment, isPlainObject, joinPath } from "
88
88
  ...walked,
89
89
  "children",
90
90
  index
91
- ])}" is. Read the document first, or address an existing position.`);
91
+ ])}" is. Call getDocument with "outline" for the pointer of each node, or address an existing position.`);
92
92
  }
93
93
  node = isPlainObject(child) ? child : void 0;
94
94
  nodeType = type;
@@ -178,6 +178,27 @@ const ELEMENT_PROPERTIES = {
178
178
  * Whether the table already says what a node type's `fields` has to be, so the
179
179
  * sub-field walk does not report the same problem a second time.
180
180
  */ const constrainsFields = (type) => "fields" in (REQUIRED_NODE_PROPERTIES[type] ?? {});
181
+ const describeConstraints = (constraints) => Object.fromEntries(Object.entries(constraints).map(([property, constraint]) => [property, needs(constraint)]).sort((left, right) => left[0].localeCompare(right[0])));
182
+ /**
183
+ * What each of the given node types has to carry, phrased the way the write
184
+ * side phrases it when it refuses one, so the listing and the error message
185
+ * never disagree. Read straight off the tables above, which is what keeps it
186
+ * true: nothing here is stated a second time.
187
+ *
188
+ * Keyed by node type rather than reported per field, because that is what it
189
+ * depends on. A field says which types it allows, in its `nodes`; what a `text`
190
+ * node has to carry is the same wherever one is written, so a response that
191
+ * describes twenty rich text fields still states it once.
192
+ *
193
+ * `type` is listed although {@link UNIVERSAL_PROPERTIES} omits it. The
194
+ * validator never reports it missing, because a node's `type` is how it finds
195
+ * the entry to check against, but a client assembling a node from this listing
196
+ * still has to write one.
197
+ */ const nodePropertiesFor = (types) => Object.fromEntries([...new Set(types)].sort().map((type) => [type, describeConstraints(type === "root" ? ROOT_PROPERTIES : {
198
+ ...REQUIRED_NODE_PROPERTIES[type],
199
+ ...UNIVERSAL_PROPERTIES,
200
+ type: "string"
201
+ })]));
181
202
  /** Present but `null` counts as present: `direction` is serialized that way. */ const check = (node, constraints) => {
182
203
  const problems = {
183
204
  missing: [],
@@ -261,4 +282,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
261
282
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
262
283
  };
263
284
  //#endregion
264
- export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, propertyProblem, rootProblems, subSchemaNodeTypes };
285
+ export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes };
@@ -1,4 +1,5 @@
1
1
  import { jsonResult } from "../result.mjs";
2
+ import { nodePropertiesFor } from "../schema/lexical.mjs";
2
3
  import { translatorFor } from "../i18n.mjs";
3
4
  import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
4
5
  import "../schema/index.mjs";
@@ -22,11 +23,11 @@ Call it with no "paths" to get a collection's own fields. Every "blocks" field s
22
23
 
23
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.
24
25
 
25
- 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. Every node needs a "version"; the root takes exactly "children", "direction", "format", "indent", "type" and "version" and refuses anything else; each node type adds its own on top. 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.
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.
26
27
 
27
- 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 "/-". Read the document first: which node sits at an index is only knowable from what is stored.
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.
28
29
 
29
- 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".
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".
30
31
 
31
32
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
32
33
  annotations: {
@@ -57,6 +58,8 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
57
58
  };
58
59
  }
59
60
  });
61
+ const nodeTypes = nodes.flatMap((node) => (node.fields ?? []).flatMap((field) => field.nodes ?? []));
62
+ if (nodeTypes.length > 0) nodes.push({ nodeProperties: nodePropertiesFor(nodeTypes) });
60
63
  if (expanded?.truncated) nodes.push({ error: `Result truncated after ${String(400)} nodes. Request explicit paths instead.` });
61
64
  return Promise.resolve(jsonResult(nodes));
62
65
  }
@@ -14,7 +14,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
14
14
 
15
15
  ${draftSentence(scope)}
16
16
 
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.
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".
18
18
 
19
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
20
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.16",
4
+ "version": "1.0.0-beta.17",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",