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

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 (69) hide show
  1. package/README.md +141 -448
  2. package/dist/{client → admin}/capability-matrix.d.mts +3 -4
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +1 -1
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +6 -6
  8. package/dist/api-keys/capability-matrix.mjs +15 -85
  9. package/dist/api-keys/fields.mjs +15 -24
  10. package/dist/auth/resolve.mjs +23 -5
  11. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +2 -1
  12. package/dist/capabilities.mjs +34 -42
  13. package/dist/define-tool.d.mts +19 -0
  14. package/dist/{types.mjs → define-tool.mjs} +1 -1
  15. package/dist/endpoint/errors.mjs +5 -6
  16. package/dist/endpoint/handler.mjs +49 -19
  17. package/dist/endpoint/server.mjs +65 -19
  18. package/dist/entity.d.mts +9 -0
  19. package/dist/guards.mjs +5 -0
  20. package/dist/i18n.mjs +8 -8
  21. package/dist/index.d.mts +5 -2
  22. package/dist/index.mjs +3 -2
  23. package/dist/options.mjs +51 -43
  24. package/dist/plugin.mjs +0 -1
  25. package/dist/request.d.mts +2 -3
  26. package/dist/request.mjs +2 -3
  27. package/dist/result.d.mts +2 -3
  28. package/dist/result.mjs +3 -3
  29. package/dist/schema/admin-hidden.mjs +91 -0
  30. package/dist/schema/describe.mjs +17 -33
  31. package/dist/schema/errors.mjs +8 -0
  32. package/dist/schema/index.mjs +5 -2
  33. package/dist/schema/lexical-pointer.mjs +18 -20
  34. package/dist/schema/lexical.mjs +39 -92
  35. package/dist/schema/outline.mjs +5 -15
  36. package/dist/schema/path.mjs +22 -0
  37. package/dist/schema/pointer.mjs +18 -25
  38. package/dist/schema/shape.mjs +25 -71
  39. package/dist/schema/walk.mjs +35 -45
  40. package/dist/tools/builtin.mjs +6 -4
  41. package/dist/tools/create-document.mjs +26 -23
  42. package/dist/tools/describe-schema.mjs +38 -34
  43. package/dist/tools/document.mjs +84 -0
  44. package/dist/tools/find-documents.mjs +32 -19
  45. package/dist/tools/find-versions.mjs +74 -0
  46. package/dist/tools/get-document.mjs +80 -49
  47. package/dist/tools/list-capabilities.mjs +25 -16
  48. package/dist/tools/patch-document.mjs +74 -50
  49. package/dist/tools/publish-document.mjs +17 -28
  50. package/dist/tools/query-paths.mjs +55 -0
  51. package/dist/tools/read-request.d.mts +10 -0
  52. package/dist/tools/read-request.mjs +43 -0
  53. package/dist/tools/shared.mjs +65 -96
  54. package/dist/tools/validate-document.mjs +16 -29
  55. package/dist/tools/versions.mjs +127 -0
  56. package/dist/types.d.mts +55 -55
  57. package/dist/write/draft-guard.mjs +17 -37
  58. package/dist/write/patch.mjs +27 -183
  59. package/dist/write/publish-blockers.mjs +7 -14
  60. package/dist/write/publish-intent.mjs +4 -4
  61. package/dist/write/row-ids.mjs +44 -0
  62. package/dist/write/transaction.mjs +5 -8
  63. package/dist/write/write-data.mjs +64 -0
  64. package/package.json +4 -4
  65. package/dist/client/capability-matrix.mjs +0 -349
  66. package/dist/endpoint/index.mjs +0 -4
  67. package/dist/tools/target.mjs +0 -45
  68. /package/dist/{client → admin}/index.d.mts +0 -0
  69. /package/dist/{client → admin}/index.mjs +0 -0
@@ -1,4 +1,5 @@
1
1
  import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
2
+ import { joinPath, splitPath } from "./path.mjs";
2
3
  import { translateAny } from "../i18n.mjs";
3
4
  import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
4
5
  //#region src/schema/walk.ts
@@ -9,21 +10,16 @@ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
9
10
  "id",
10
11
  "updatedAt"
11
12
  ]);
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);
17
- /**
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.
21
- */ const pointerFromPayloadPath = (path) => path ? joinPath(path.split(".")) : "";
13
+ /** The keys a row carries beside its fields. */ const ROW_KEYS = /* @__PURE__ */ new Set([
14
+ "blockName",
15
+ "blockType",
16
+ "id"
17
+ ]);
22
18
  /** 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))];
23
19
  /**
24
20
  * An inlined definition wins over the registry (`config.blocks`). A block's own
25
21
  * 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.
22
+ * are not, so an inline definition must be read at its position.
27
23
  */ const blockOf = (config, field, slug) => {
28
24
  const declared = field.blockReferences ?? field.blocks;
29
25
  const inline = declared.find((block) => typeof block !== "string" && block.slug === slug);
@@ -32,9 +28,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
32
28
  };
33
29
  /**
34
30
  * Payload's `fieldIsHiddenOrDisabled` reads `hidden` and `admin.disabled`, not
35
- * `admin.hidden`, which is what its own upload base fields carry.
31
+ * `admin.hidden`, which its own upload base fields carry.
36
32
  */ 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);
33
+ const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || isAdminHidden(field) || fieldIsHiddenOrDisabled(field);
38
34
  const isReadOnly = (field) => "admin" in field && field.admin.readOnly === true;
39
35
  const describeBase = (field, { path, readOnly, translate }) => {
40
36
  const description = translate("admin" in field ? field.admin.description : void 0);
@@ -68,28 +64,15 @@ const withRows = (descriptor, field) => ({
68
64
  ...field.minRows === void 0 ? {} : { minRows: field.minRows },
69
65
  ...field.maxRows === void 0 ? {} : { maxRows: field.maxRows }
70
66
  });
67
+ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
68
+ const isInformative = (descriptor) => descriptor.description !== void 0 || descriptor.required === true || descriptor.localized === true;
71
69
  /**
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.
75
- */ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
76
- /**
77
- * Whether a container declares anything a client could not infer from the
78
- * fields beneath it. A group that exists only to nest is not worth reporting.
79
- */ const isInformative = (descriptor) => descriptor.description !== void 0 || descriptor.required === true || descriptor.localized === true;
80
- /**
81
- * Flattens a field list into descriptors addressed relative to the node.
82
- *
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.
91
- *
92
- * Omitting `translate` costs language selection, never the description itself.
70
+ * Named tabs, groups and arrays add a path segment and are described
71
+ * themselves only when they declare something of their own: an array always,
72
+ * since its row counts live nowhere else. The walk stops at every blocks field
73
+ * and names the slugs, so a node's size follows the number of blocks it
74
+ * allows, not their definitions. Omitting `translate` costs language
75
+ * selection, never the description itself.
93
76
  */ const describeFields = (fields, translate = translateAny) => {
94
77
  const walk = (current, prefix, parentReadOnly) => current.flatMap((field) => {
95
78
  if (isSkipped(field)) return [];
@@ -114,10 +97,17 @@ const withRows = (descriptor, field) => ({
114
97
  return walk(fields, [], false);
115
98
  };
116
99
  /**
117
- * The descriptors that address a value, which is what every walk resolving a
118
- * path against a document needs. A container describes a position rather than
119
- * a value, so only {@link nodeDescriber} reports one.
100
+ * The descriptors that address a value, which every walk resolving a path
101
+ * against a document needs. Containers are left out, since only
102
+ * {@link nodeDescriber} reports one.
120
103
  */ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
104
+ /** The addressable descriptors under `prefix`, with their paths split relative to it. */ const descriptorsUnder = (fields, prefix) => describeAddressableFields(fields).flatMap((descriptor) => {
105
+ const parts = splitPath(descriptor.path);
106
+ return prefix.every((part, offset) => part === parts[offset]) ? [{
107
+ descriptor,
108
+ parts: parts.slice(prefix.length)
109
+ }] : [];
110
+ });
121
111
  const findFieldAt = (fields, path, type) => {
122
112
  for (const field of fields) {
123
113
  if (!("name" in field) || field.name !== path[0]) continue;
@@ -128,18 +118,18 @@ const findFieldAt = (fields, path, type) => {
128
118
  };
129
119
  const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
130
120
  /**
131
- * Locates the rich text field that a resolved descriptor path refers to, so
132
- * its editor can be introspected for the fields its nodes carry.
121
+ * Locates the rich text field a resolved descriptor path refers to, so its
122
+ * editor can be introspected for the fields its nodes carry.
133
123
  */ const findRichTextField = (fields, path) => findFieldAt(fields, path, "richText");
134
124
  /**
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) => {
125
+ * Looks up the sanitized config for a collection or global as an
126
+ * {@link EntitySchema}. Throws on an unknown slug instead of returning
127
+ * undefined: a reference reaching here was already checked against the key's
128
+ * capabilities, so a miss means the config changed underneath it.
129
+ */ const schemaOf = (config, ref) => {
140
130
  const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
141
131
  if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
142
132
  return found;
143
133
  };
144
134
  //#endregion
145
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf };
135
+ export { RESERVED_FIELD_NAMES, ROW_KEYS, blockOf, blockSlugsOf, describeAddressableFields, describeFields, descriptorsUnder, findBlocksField, findRichTextField, isAdminHidden, schemaOf };
@@ -1,6 +1,7 @@
1
1
  import { createDocument } from "./create-document.mjs";
2
2
  import { describeSchema } from "./describe-schema.mjs";
3
3
  import { findDocuments } from "./find-documents.mjs";
4
+ import { findVersions } from "./find-versions.mjs";
4
5
  import { getDocument } from "./get-document.mjs";
5
6
  import { listCapabilities } from "./list-capabilities.mjs";
6
7
  import { patchDocument } from "./patch-document.mjs";
@@ -8,15 +9,16 @@ import { publishDocument } from "./publish-document.mjs";
8
9
  import { validateDocument } from "./validate-document.mjs";
9
10
  //#region src/tools/builtin.ts
10
11
  /**
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.
12
+ * The builtin tools, in registration order. The set is fixed, so adding a
13
+ * collection, block or field never changes it. They differ from custom tools
14
+ * only in `isEnabled`, which derives from the key's capabilities instead of a
15
+ * checkbox of its own.
15
16
  */ const BUILTIN_TOOLS = [
16
17
  listCapabilities,
17
18
  describeSchema,
18
19
  findDocuments,
19
20
  getDocument,
21
+ findVersions,
20
22
  patchDocument,
21
23
  createDocument,
22
24
  validateDocument,
@@ -1,29 +1,29 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
1
2
  import { errorResult, jsonResult } from "../result.mjs";
2
3
  import { validateWriteValue } from "../schema/shape.mjs";
3
4
  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";
7
- import { stripRowIds } from "../write/patch.mjs";
5
+ import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
6
+ import { readDraft, resolveEntity } from "./document.mjs";
8
7
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
8
+ import { stripRowIds } from "../write/row-ids.mjs";
9
9
  import { z } from "zod";
10
10
  //#region src/tools/create-document.ts
11
- /** Names the writable slugs this tool leaves out, so the gap reads as intent. */ const uploadSentence = (scope) => {
11
+ const uploadSentence = (scope) => {
12
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.`;
13
+ return slugs.length === 0 ? "" : ` Documents in ${slugs.join(", ")} are files and cannot be created here. Upload the file in the admin panel, then edit its fields with patchDocument.`;
14
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.
15
+ const DESCRIPTION = (scope) => `Creates a document in a collection. Use it only when the document does not exist yet. Returns "id", "status", "updatedAt" and, if any, "publishBlockers". "publishBlockersUnavailable" means that check failed. "data" may leave required fields empty for patchDocument to fill later, except in a collection whose listCapabilities entry has "draftValidation" true or "drafts" false. A field describeSchema does not list is refused, and "id" is always assigned.
16
16
 
17
- ${draftSentence(scope)}${uploadSentence(scope)}`;
17
+ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
18
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.
19
+ * Collection-only, because a global always exists. Upload collections are
20
+ * excluded because a create there would need the file.
21
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.
22
+ * The seed is checked against the collection's fields first, so an unknown key
23
+ * is refused along with its valid siblings. Row ids in the seed are stripped
24
+ * and a top-level `id` is refused. The new document is re-read privileged to
25
+ * collect publish blockers, so an incomplete seed still succeeds and returns a
26
+ * checklist.
27
27
  */ const createDocument = defineMcpxTool({
28
28
  name: "createDocument",
29
29
  description: DESCRIPTION,
@@ -35,18 +35,19 @@ ${draftSentence(scope)}${uploadSentence(scope)}`;
35
35
  },
36
36
  isEnabled: (scope) => slugsFor(scope, "create").collections.length > 0,
37
37
  inputSchema: (scope) => ({
38
- collection: slugEnum(slugsFor(scope, "create").collections).describe("Collection to create the document in."),
38
+ collection: slugEnum(slugsFor(scope, "create").collections),
39
39
  ...localeShape(scope, {
40
40
  required: true,
41
- description: "Locale the localized fields of the seed belong to."
41
+ description: "Locale of the localized fields in \"data\"."
42
42
  }),
43
- data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
43
+ data: z.record(z.string(), z.unknown()).describe("Field values by name, e.g. {\"title\":\"Home\",\"slug\":\"home\"}.")
44
44
  }),
45
45
  handler: async ({ args, scope }) => {
46
- const target = resolveTarget(scope, { collection: args.collection }, "create");
46
+ const target = resolveEntity(scope, { collection: args.collection }, "create");
47
+ if (target.kind === "global") throw new Error("createDocument resolved a global.");
47
48
  const { payload } = scope.req;
48
49
  const locale = localeOf(scope, args.locale);
49
- if ("id" in args.data) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
50
+ if (Object.hasOwn(args.data, "id")) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
50
51
  const problems = validateWriteValue(payload.config, {
51
52
  pointer: "",
52
53
  resolution: {
@@ -64,9 +65,11 @@ ${draftSentence(scope)}${uploadSentence(scope)}`;
64
65
  req: scope.req,
65
66
  ...locale === void 0 ? {} : { locale }
66
67
  });
67
- const saved = await readTarget(scope, {
68
- target,
69
- id: created["id"],
68
+ const saved = await readDraft(scope, {
69
+ target: {
70
+ ...target,
71
+ id: created["id"]
72
+ },
70
73
  locale,
71
74
  privileged: true
72
75
  });
@@ -1,67 +1,71 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
1
2
  import { jsonResult } from "../result.mjs";
2
3
  import { nodePropertiesFor } from "../schema/lexical.mjs";
3
4
  import { translatorFor } from "../i18n.mjs";
5
+ import { SchemaError } from "../schema/errors.mjs";
4
6
  import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
5
7
  import "../schema/index.mjs";
6
- import { targetShape } from "./shared.mjs";
7
- import { refOf, resolveTarget } from "./target.mjs";
8
- import { defineMcpxTool } from "../types.mjs";
8
+ import { entityShape } from "./shared.mjs";
9
+ import { resolveEntity } from "./document.mjs";
9
10
  import { z } from "zod";
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({
17
- name: "describeSchema",
18
- description: `Describes the writable shape of a document, one node at a time.
19
-
20
- Pass exactly one of "collection" and "global". A global is a singleton: it has no id, is not listed by findDocuments and cannot be created.
21
-
22
- Call it with no "paths" to get a collection's own fields. Every "blocks" field stops there and lists the block slugs it accepts instead of nesting them; each node's "next" lists the ready-to-use paths for those blocks, so pass any entry of "next" as a "paths" element to descend, e.g. "/layout/sections/sectionWrapper" and then "/layout/sections/sectionWrapper/modules/hero". A block is described as it exists at that position, because the same block can accept different children elsewhere.
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.
25
-
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.
11
+ //#region src/tools/describe-schema.ts
12
+ const DESCRIPTION = `Describes the fields of a collection or global, one node at a time. Use it before a query or a write.
27
13
 
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.
14
+ Without "paths" it returns the top-level node. A node has a "schemaPath", its "fields" and "next". Each field has a "path" relative to its node, a "type" and constraints such as "required", "localized", "readOnly", "options" and "relationTo". A "blocks" field lists the block slugs it accepts, and a "richText" field lists its node types in "nodes". "next" holds the schema paths of the blocks and rich text node types with fields below the node, e.g. "/layout/sections/sectionWrapper" or "/content/block/callout". Pass them as "paths" to describe those nodes. A block can accept different children at another position, so describe it at the schema path where it is used.
29
15
 
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".
16
+ When the response contains a rich text field, the response also carries a "nodeProperties" entry: the properties each node type must carry. Build every rich text node from it. Upload nodes have no schema path.
31
17
 
32
- Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
18
+ id, _status, createdAt and updatedAt are not listed and cannot be written. A field marked "readOnly" cannot be written either.`;
19
+ const PATHS_LIMIT = 400;
20
+ /**
21
+ * Describes each requested path independently and returns a per-path error
22
+ * object instead of failing the call, so a client exploring several branches
23
+ * keeps the nodes that resolved. `expand` replaces the requested paths with
24
+ * every node reachable from the root and adds a truncation notice past
25
+ * {@link REACHABLE_PATHS_LIMIT}.
26
+ */ const describeSchema = defineMcpxTool({
27
+ name: "describeSchema",
28
+ description: DESCRIPTION,
33
29
  annotations: {
34
30
  readOnlyHint: true,
35
31
  openWorldHint: false
36
32
  },
37
- isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
33
+ isEnabled: (scope) => scope.collections.readable.length + scope.globals.readable.length > 0,
38
34
  inputSchema: (scope) => ({
39
- ...targetShape(scope, "read", {
40
- collection: "Collection to describe.",
41
- global: "Global to describe."
42
- }),
43
- paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"/layout/sections/sectionWrapper\". Omit for the collection root."),
44
- expand: z.boolean().optional().describe("Return every node reachable from the root in one response. Ignores paths.")
35
+ ...entityShape(scope, "read", "Instead of \"collection\"."),
36
+ paths: z.array(z.string()).max(PATHS_LIMIT).optional().describe("Schema paths from \"next\". Omit for the top-level node."),
37
+ expand: z.boolean().optional().describe("Return the top-level node and every node below it in one response, instead of \"paths\".")
45
38
  }),
46
39
  handler: ({ args, scope }) => {
47
- const ref = refOf(resolveTarget(scope, args, "read"));
40
+ const ref = resolveEntity(scope, args, "read");
48
41
  const { config } = scope.req.payload;
49
42
  const describeNode = nodeDescriber(translatorFor(scope.req.i18n));
50
43
  const expanded = args.expand === true ? reachableSchemaPaths(config, ref) : void 0;
51
- const nodes = (expanded?.paths ?? (args.paths && args.paths.length > 0 ? args.paths : [""])).map((schemaPath) => {
44
+ const requested = expanded?.paths ?? (args.paths && args.paths.length > 0 ? args.paths : [""]);
45
+ const failures = [];
46
+ const nodes = requested.map((schemaPath) => {
52
47
  try {
53
48
  return describeNode(config, ref, schemaPath);
54
49
  } catch (error) {
50
+ if (error instanceof SchemaError) return {
51
+ error: error.message,
52
+ schemaPath
53
+ };
54
+ failures.push(error);
55
55
  return {
56
- error: error instanceof Error ? error.message : "Unknown error",
56
+ error: "Internal error",
57
57
  schemaPath
58
58
  };
59
59
  }
60
60
  });
61
+ if (failures.length > 0) scope.req.payload.logger.error({
62
+ err: failures[0],
63
+ msg: `[payloadcms-mcpx] Describing ${String(failures.length)} schema paths failed.`
64
+ });
61
65
  const nodeTypes = nodes.flatMap((node) => (node.fields ?? []).flatMap((field) => field.nodes ?? []));
62
66
  if (nodeTypes.length > 0) nodes.push({ nodeProperties: nodePropertiesFor(nodeTypes) });
63
67
  if (expanded?.truncated) nodes.push({ error: `Result truncated after ${String(400)} nodes. Request explicit paths instead.` });
64
- return Promise.resolve(jsonResult(nodes));
68
+ return jsonResult(nodes);
65
69
  }
66
70
  });
67
71
  //#endregion
@@ -0,0 +1,84 @@
1
+ import { errorResult } from "../result.mjs";
2
+ import { slugsFor } from "./shared.mjs";
3
+ import { APIError, Forbidden, NotFound } from "payload";
4
+ //#region src/tools/document.ts
5
+ /**
6
+ * A raw input shape leaves no top-level `.refine` to express "exactly one of
7
+ * collection and global", so the rule is enforced here, with a message naming
8
+ * the offending arguments.
9
+ */ const resolveEntity = (scope, args, operation) => {
10
+ const { collection, global } = args;
11
+ const allowedSlugs = slugsFor(scope, operation);
12
+ if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
13
+ if (collection === void 0 && global === void 0) throw new APIError("One of \"collection\" or \"global\" is required. Call listCapabilities to see which slugs are available.", 400);
14
+ if (collection !== void 0) {
15
+ const found = scope.req.payload.collections[collection];
16
+ if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
17
+ return {
18
+ kind: "collection",
19
+ slug: collection,
20
+ config: found.config
21
+ };
22
+ }
23
+ const slug = global;
24
+ const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
25
+ if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
26
+ return {
27
+ kind: "global",
28
+ slug,
29
+ config: found
30
+ };
31
+ };
32
+ /**
33
+ * Resolves the entity and checks `id` against it. A collection document needs
34
+ * one; a global is a singleton and must not carry one. The schema cannot
35
+ * express that, so it is checked here and stated on the `id` parameter.
36
+ */ const resolveDocument = (scope, args, operation) => {
37
+ const target = resolveEntity(scope, args, operation);
38
+ if (target.kind === "global") {
39
+ if (args.id !== void 0) throw new APIError(`"id" must be omitted when "global" is "${target.slug}"; a global is a singleton.`, 400);
40
+ return target;
41
+ }
42
+ if (args.id === void 0) throw new APIError(`"id" is required when "collection" is "${target.slug}".`, 400);
43
+ return {
44
+ ...target,
45
+ id: args.id
46
+ };
47
+ };
48
+ /** What a response names its subject by: a document's id, or a global's slug. */ const identityOf = (target, id) => target.kind === "collection" ? { id } : { global: target.slug };
49
+ const sameInstant = (left, right) => (typeof left === "string" || left instanceof Date) && new Date(left).getTime() === new Date(right).getTime();
50
+ /** Refuses a write when the document changed since the client read it. */ const staleReadResult = (doc, expectedUpdatedAt, message) => expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], expectedUpdatedAt) ? errorResult(message, { updatedAt: doc["updatedAt"] }) : void 0;
51
+ /**
52
+ * Reads the current draft in a fixed locale with no fallback, which is the
53
+ * shape that may be written back or validated without mixing locales.
54
+ */ const readDraft = async (scope, args) => {
55
+ const { payload } = scope.req;
56
+ const privileged = args.privileged === true;
57
+ const shared = {
58
+ depth: 0,
59
+ draft: true,
60
+ ...args.locale === void 0 ? {} : {
61
+ locale: args.locale,
62
+ fallbackLocale: false
63
+ },
64
+ overrideAccess: privileged,
65
+ showHiddenFields: privileged,
66
+ req: scope.req
67
+ };
68
+ if (args.target.kind === "collection") {
69
+ const doc = await payload.findByID({
70
+ ...shared,
71
+ collection: args.target.slug,
72
+ id: args.target.id,
73
+ disableErrors: true
74
+ });
75
+ if (!doc) throw new NotFound(scope.req.t);
76
+ return doc;
77
+ }
78
+ return await payload.findGlobal({
79
+ ...shared,
80
+ slug: args.target.slug
81
+ });
82
+ };
83
+ //#endregion
84
+ export { identityOf, readDraft, resolveDocument, resolveEntity, staleReadResult };
@@ -1,47 +1,57 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
1
2
  import { jsonResult } from "../result.mjs";
2
- import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
- import { resolveTarget } from "./target.mjs";
4
- import { defineMcpxTool } from "../types.mjs";
3
+ import { stripAdminHidden } from "../schema/admin-hidden.mjs";
4
+ import "../schema/index.mjs";
5
+ import { READ_LOCALE_DESCRIPTION, depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
6
+ import { resolveEntity } from "./document.mjs";
7
+ import { assertQueryable } from "./query-paths.mjs";
8
+ import { mcpxReadRequest } from "./read-request.mjs";
5
9
  import { z } from "zod";
6
10
  /**
7
11
  * Collection-only: a global is a singleton, so there is nothing to list.
8
12
  *
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
+ * The query uses `overrideAccess: false`, so the collection's own access
14
+ * control decides what comes back. The schema bounds `limit` and `depth` by the
15
+ * configured limits, so the client sees the ceiling instead of being clamped
16
+ * silently.
13
17
  */ const findDocuments = defineMcpxTool({
14
18
  name: "findDocuments",
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.`,
19
+ description: `Finds documents in a collection. Returns one page of "docs" with paging totals. "where" is a Payload query, e.g. {"title":{"contains":"home"}}, with dots for nested fields, e.g. "items.heading". A "where" or "sort" on a hidden field, or through a relationship into a collection this key cannot read, is refused.`,
16
20
  annotations: {
17
21
  readOnlyHint: true,
18
22
  openWorldHint: false
19
23
  },
20
- isEnabled: (scope) => scope.readable.length > 0,
24
+ isEnabled: (scope) => scope.collections.readable.length > 0,
21
25
  inputSchema: (scope) => ({
22
- collection: slugEnum(scope.readable).describe("Collection to search."),
23
- where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
24
- sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
25
- limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.limits.maxLimit)}.`),
26
- page: z.number().int().min(1).optional().describe("Page number, from 1."),
26
+ collection: slugEnum(scope.collections.readable),
27
+ where: z.record(z.string(), z.unknown()).optional(),
28
+ sort: z.string().optional().describe("e.g. \"-updatedAt\" for descending."),
29
+ limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe("Default 10."),
30
+ page: z.number().int().min(1).optional(),
27
31
  ...depthShape(scope),
28
32
  select: z.record(z.string(), z.unknown()).optional().describe("Fields to return, e.g. {\"title\":true}."),
29
33
  ...localeShape(scope, {
30
34
  required: false,
31
- description: "Locale to read. Defaults to the default locale."
35
+ description: READ_LOCALE_DESCRIPTION
32
36
  }),
33
- draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
37
+ draft: z.boolean().optional().describe("Default true: latest drafts.")
34
38
  }),
35
39
  handler: async ({ args, scope }) => {
36
- resolveTarget(scope, { collection: args.collection }, "read");
40
+ resolveEntity(scope, { collection: args.collection }, "read");
37
41
  const locale = localeOf(scope, args.locale);
42
+ assertQueryable(scope, {
43
+ collection: args.collection,
44
+ where: args.where,
45
+ sort: args.sort,
46
+ locale
47
+ });
38
48
  const result = await scope.req.payload.find({
39
49
  collection: args.collection,
40
50
  depth: args.depth ?? 0,
41
51
  draft: args.draft ?? true,
42
52
  limit: args.limit ?? 10,
43
53
  overrideAccess: false,
44
- req: scope.req,
54
+ req: mcpxReadRequest(scope),
45
55
  ...args.page === void 0 ? {} : { page: args.page },
46
56
  ...args.sort === void 0 ? {} : { sort: args.sort },
47
57
  ...args.where === void 0 ? {} : { where: args.where },
@@ -49,7 +59,10 @@ import { z } from "zod";
49
59
  ...locale === void 0 ? {} : { locale }
50
60
  });
51
61
  return jsonResult({
52
- docs: result.docs,
62
+ docs: result.docs.map((doc) => stripAdminHidden(scope.req.payload.config, {
63
+ kind: "collection",
64
+ slug: args.collection
65
+ }, doc)),
53
66
  totalDocs: result.totalDocs,
54
67
  page: result.page,
55
68
  totalPages: result.totalPages,
@@ -0,0 +1,74 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
2
+ import { jsonResult } from "../result.mjs";
3
+ import { draftVersionSlugs, entityShape, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
4
+ import { resolveDocument } from "./document.mjs";
5
+ import { queryVersions } from "./versions.mjs";
6
+ import { APIError } from "payload";
7
+ import { hasDraftsEnabled } from "payload/shared";
8
+ import { z } from "zod";
9
+ //#region src/tools/find-versions.ts
10
+ const DESCRIPTION = `Lists the versions of one document or global, newest first, autosaves included. Returns per version "versionId", "createdAt", "updatedAt", "status", "latest", "autosave" and, where set, "publishedLocale", without the content. Read a version with getDocument "versionId", or compare it with "diffFrom".`;
11
+ const statusShape = (scope) => {
12
+ const { collections, globals } = draftVersionSlugs(scope);
13
+ if (collections.length + globals.length === 0) return widen({});
14
+ return widen({ status: z.enum(["published", "draft"]).optional() });
15
+ };
16
+ /**
17
+ * Bodies are left out so a long history stays small; `getDocument` reads one
18
+ * when it is needed. The document's `read` access is checked first, then
19
+ * Payload's `readVersions`. Only entities with `versions: true` in the plugin
20
+ * options are reachable.
21
+ */ const findVersions = defineMcpxTool({
22
+ name: "findVersions",
23
+ description: DESCRIPTION,
24
+ annotations: {
25
+ readOnlyHint: true,
26
+ openWorldHint: false
27
+ },
28
+ isEnabled: (scope) => {
29
+ const { collections, globals } = slugsFor(scope, "versions");
30
+ return collections.length + globals.length > 0;
31
+ },
32
+ inputSchema: (scope) => ({
33
+ ...entityShape(scope, "versions"),
34
+ ...idShape(scope, "versions"),
35
+ limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe("Default 10."),
36
+ page: z.number().int().min(1).optional(),
37
+ ...statusShape(scope),
38
+ ...localeShape(scope, {
39
+ required: false,
40
+ description: "Locale whose \"status\" is reported. Defaults to the default locale."
41
+ })
42
+ }),
43
+ handler: async ({ args, scope }) => {
44
+ const target = resolveDocument(scope, args, "versions");
45
+ if (args.status !== void 0 && !hasDraftsEnabled(target.config)) throw new APIError(`"${target.slug}" has no drafts, so its versions carry no status.`, 400);
46
+ const result = await queryVersions(scope, {
47
+ target,
48
+ depth: 0,
49
+ locale: localeOf(scope, args.locale)
50
+ }, {
51
+ limit: args.limit ?? 10,
52
+ ...args.page === void 0 ? {} : { page: args.page },
53
+ ...args.status === void 0 ? {} : { status: args.status }
54
+ });
55
+ const versions = result.docs.map((doc) => ({
56
+ versionId: doc.id,
57
+ createdAt: doc["createdAt"],
58
+ updatedAt: doc["updatedAt"],
59
+ status: doc.version["_status"] ?? null,
60
+ latest: doc["latest"] === true,
61
+ autosave: doc["autosave"] === true,
62
+ ...doc["publishedLocale"] ? { publishedLocale: doc["publishedLocale"] } : {}
63
+ }));
64
+ return jsonResult({
65
+ versions,
66
+ totalDocs: result.totalDocs,
67
+ page: result.page,
68
+ totalPages: result.totalPages,
69
+ hasNextPage: result.hasNextPage
70
+ });
71
+ }
72
+ });
73
+ //#endregion
74
+ export { findVersions };