@abinnovision/payloadcms-mcpx 1.0.0-beta.13 → 1.0.0-beta.15

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.
@@ -2,50 +2,37 @@ 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, "~"));
14
15
  /**
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.
16
+ * A path Payload reports on a validation error (`layout.0.title`) as a JSON
17
+ * Pointer, so everything handed back addresses documents the same way. The
18
+ * path already carries real indices, so it maps directly.
30
19
  */ const pointerFromPayloadPath = (path) => path ? joinPath(path.split(".")) : "";
20
+ /** 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
21
  /**
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.
22
+ * An inlined definition wins over the registry (`config.blocks`). A block's own
23
+ * fields are identical wherever it appears, but the blocks its children accept
24
+ * are not, so an inline definition has to be read at its position.
42
25
  */ const blockOf = (config, field, slug) => {
43
26
  const declared = field.blockReferences ?? field.blocks;
44
27
  const inline = declared.find((block) => typeof block !== "string" && block.slug === slug);
45
28
  if (inline) return inline;
46
29
  return declared.includes(slug) ? config.blocks?.find((block) => block.slug === slug) : void 0;
47
30
  };
48
- const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || fieldIsHiddenOrDisabled(field);
31
+ /**
32
+ * Payload's `fieldIsHiddenOrDisabled` reads `hidden` and `admin.disabled`, not
33
+ * `admin.hidden`, which is what its own upload base fields carry.
34
+ */ const isAdminHidden = (field) => "admin" in field && field.admin.hidden === true;
35
+ /** 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
36
  const isReadOnly = (field) => "admin" in field && field.admin.readOnly === true;
50
37
  const describeBase = (field, { path, readOnly, translate }) => {
51
38
  const description = translate("admin" in field ? field.admin.description : void 0);
@@ -80,11 +67,9 @@ const withRows = (descriptor, field) => ({
80
67
  ...field.maxRows === void 0 ? {} : { maxRows: field.maxRows }
81
68
  });
82
69
  /**
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 nodeDescriber} reports
87
- * them, to carry what the container itself declares.
70
+ * A container describes a position rather than a value, so everything resolving
71
+ * a path to something writable skips it. Only {@link nodeDescriber} reports one,
72
+ * to carry what the container itself declares.
88
73
  */ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
89
74
  /**
90
75
  * Whether a container declares anything a client could not infer from the
@@ -93,20 +78,16 @@ const withRows = (descriptor, field) => ({
93
78
  /**
94
79
  * Flattens a field list into descriptors addressed relative to the node.
95
80
  *
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.
81
+ * The input is Payload's own flattened shape, so the admin-only constructs
82
+ * (unnamed tabs and groups, `row`, `collapsible`, `ui`) are already gone. Named
83
+ * tabs, groups and arrays contribute a path segment, and are described in their
84
+ * own right when they declare something of their own: an array always, since
85
+ * its row counts live nowhere else, a group or tab only when it carries a
86
+ * description or a constraint. The walk stops at every blocks field and names
87
+ * the slugs, which keeps a node proportional to the number of blocks it allows
88
+ * rather than to the size of their definitions.
105
89
  *
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.
90
+ * Omitting `translate` costs language selection, never the description itself.
110
91
  */ const describeFields = (fields, translate = translateAny) => {
111
92
  const walk = (current, prefix, parentReadOnly) => current.flatMap((field) => {
112
93
  if (isSkipped(field)) return [];
@@ -135,9 +116,7 @@ const withRows = (descriptor, field) => ({
135
116
  * path against a document needs. A container describes a position rather than
136
117
  * a value, so only {@link nodeDescriber} reports one.
137
118
  */ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
138
- /**
139
- * Locates the field of `type` that a resolved descriptor path refers to.
140
- */ const findFieldAt = (fields, path, type) => {
119
+ const findFieldAt = (fields, path, type) => {
141
120
  for (const field of fields) {
142
121
  if (!("name" in field) || field.name !== path[0]) continue;
143
122
  if (field.type === type && path.length === 1) return field;
@@ -145,14 +124,17 @@ const withRows = (descriptor, field) => ({
145
124
  if (field.type === "array" && path[1] === "*") return findFieldAt(field.flattenedFields, path.slice(2), type);
146
125
  }
147
126
  };
148
- /**
149
- * Locates the blocks field that a resolved descriptor path refers to.
150
- */ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
127
+ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
151
128
  /**
152
129
  * Locates the rich text field that a resolved descriptor path refers to, so
153
130
  * its editor can be introspected for the fields its nodes carry.
154
131
  */ const findRichTextField = (fields, path) => findFieldAt(fields, path, "richText");
155
- const targetOf = (config, ref) => {
132
+ /**
133
+ * Looks up the sanitized config for a collection or global, as a
134
+ * {@link SchemaTarget}. Throws on an unknown slug rather than returning
135
+ * undefined, because a reference reaching here has already been checked against
136
+ * the key's capabilities and a miss means the config changed underneath it.
137
+ */ const targetOf = (config, ref) => {
156
138
  const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
157
139
  if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
158
140
  return found;
@@ -8,12 +8,10 @@ import { publishDocument } from "./publish-document.mjs";
8
8
  import { validateDocument } from "./validate-document.mjs";
9
9
  //#region src/tools/builtin.ts
10
10
  /**
11
- * The builtin tools in registration order. They are ordinary {@link McpxTool}s
12
- * that ship with the plugin and register through the same loop as the tools
13
- * from `options.tools`; only their `isEnabled` differs, deriving from the
14
- * key's collection and global capabilities rather than a checkbox of their
15
- * own. The surface is fixed: adding a collection, block or field never
16
- * changes it.
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.
17
15
  */ const BUILTIN_TOOLS = [
18
16
  listCapabilities,
19
17
  describeSchema,
@@ -1,17 +1,30 @@
1
1
  import { errorResult, jsonResult } from "../result.mjs";
2
2
  import { validateWriteValue } from "../schema/shape.mjs";
3
3
  import "../schema/index.mjs";
4
- import { draftSentence, localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
4
+ import { draftSentence, localeOf, localeShape, patchOnlySlugs, readTarget, slugEnum, slugsFor } from "./shared.mjs";
5
5
  import { resolveTarget } from "./target.mjs";
6
6
  import { defineMcpxTool } from "../types.mjs";
7
7
  import { stripRowIds } from "../write/patch.mjs";
8
8
  import { collectPublishBlockers } from "../write/publish-blockers.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) => {
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
+ };
11
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.
12
16
 
13
- ${draftSentence(scope)}`;
14
- const createDocument = defineMcpxTool({
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({
15
28
  name: "createDocument",
16
29
  description: DESCRIPTION,
17
30
  annotations: {
@@ -20,9 +33,9 @@ const createDocument = defineMcpxTool({
20
33
  idempotentHint: false,
21
34
  openWorldHint: false
22
35
  },
23
- isEnabled: (scope) => scope.writable.length > 0,
36
+ isEnabled: (scope) => slugsFor(scope, "create").collections.length > 0,
24
37
  inputSchema: (scope) => ({
25
- 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."),
26
39
  ...localeShape(scope, {
27
40
  required: true,
28
41
  description: "Locale the localized fields of the seed belong to."
@@ -30,7 +43,7 @@ const createDocument = defineMcpxTool({
30
43
  data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
31
44
  }),
32
45
  handler: async ({ args, scope }) => {
33
- const target = resolveTarget(scope, { collection: args.collection }, "write");
46
+ const target = resolveTarget(scope, { collection: args.collection }, "create");
34
47
  const { payload } = scope.req;
35
48
  const locale = localeOf(scope, args.locale);
36
49
  if ("id" in args.data) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
@@ -6,7 +6,13 @@ import { targetShape } from "./shared.mjs";
6
6
  import { refOf, resolveTarget } from "./target.mjs";
7
7
  import { defineMcpxTool } from "../types.mjs";
8
8
  import { z } from "zod";
9
- const describeSchema = defineMcpxTool({
9
+ /**
10
+ * Describes each requested path independently and returns a per
11
+ * path error object instead of failing the call, so a client exploring several
12
+ * branches at once keeps the nodes that did resolve. `expand` swaps the
13
+ * requested paths for every node reachable from the root and appends a
14
+ * truncation notice past {@link REACHABLE_PATHS_LIMIT}.
15
+ */ const describeSchema = defineMcpxTool({
10
16
  name: "describeSchema",
11
17
  description: `Describes the writable shape of a document, one node at a time.
12
18
 
@@ -16,6 +22,8 @@ Call it with no "paths" to get a collection's own fields. Every "blocks" field s
16
22
 
17
23
  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.
18
24
 
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.
26
+
19
27
  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".
20
28
 
21
29
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
@@ -3,7 +3,14 @@ import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
3
  import { resolveTarget } from "./target.mjs";
4
4
  import { defineMcpxTool } from "../types.mjs";
5
5
  import { z } from "zod";
6
- const findDocuments = defineMcpxTool({
6
+ /**
7
+ * Collection-only: a global is a singleton, so there is nothing to list.
8
+ *
9
+ * The query goes to Payload with `overrideAccess: false`, so the collection's
10
+ * own access control decides what comes back. `limit` and `depth` are bounded
11
+ * by the configured limits in the schema itself, which puts the ceiling in
12
+ * front of the client rather than silently clamping behind it.
13
+ */ const findDocuments = defineMcpxTool({
7
14
  name: "findDocuments",
8
15
  description: `Finds documents in a collection. "where" is a Payload query object, e.g. {"title":{"contains":"home"}} or {"and":[...]}; "select" picks fields, e.g. {"title":true}. Drafts are included by default so unpublished work is visible. Keep depth at 0 unless populated relationships are needed; ids are enough for writes.`,
9
16
  annotations: {
@@ -6,7 +6,11 @@ import { requireIdFor, resolveTarget } from "./target.mjs";
6
6
  import { defineMcpxTool } from "../types.mjs";
7
7
  import { z } from "zod";
8
8
  import { Pointer } from "rfc6902";
9
- const getDocument = defineMcpxTool({
9
+ /**
10
+ * With `path` the handler returns the subtree plus the `id`, `_status` and
11
+ * `updatedAt` a client needs to write back, so a caller reading one branch
12
+ * still gets the timestamp `expectedUpdatedAt` wants without a second call.
13
+ */ const getDocument = defineMcpxTool({
10
14
  name: "getDocument",
11
15
  description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
12
16
 
@@ -1,11 +1,18 @@
1
+ import { canCreate } from "../capabilities.mjs";
1
2
  import { jsonResult } from "../result.mjs";
2
3
  import { translatorFor } from "../i18n.mjs";
3
4
  import { translateLabel } from "./shared.mjs";
4
5
  import { defineMcpxTool } from "../types.mjs";
5
6
  import { hasDraftValidationEnabled } from "payload/shared";
6
- const listCapabilities = defineMcpxTool({
7
+ /**
8
+ * Registered for every key, including one with no capabilities ticked, so a
9
+ * client always has something to call and gets an empty surface described
10
+ * rather than an empty tool list. The response is assembled from the request
11
+ * scope and the sanitized config, never from the content model, so it stays the
12
+ * same size as a deployment grows.
13
+ */ const listCapabilities = defineMcpxTool({
7
14
  name: "listCapabilities",
8
- description: `Lists what this key may do: the collections and globals it can read or write, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
15
+ description: `Lists what this key may do: the collections and globals it can read or write, whether a collection can also be created in, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
9
16
 
10
17
  A global is a singleton: it has no id, is not listed by findDocuments and cannot be created. Address one with the "global" argument where a collection document would take "collection" and "id".`,
11
18
  annotations: {
@@ -32,6 +39,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
32
39
  ...description === void 0 ? {} : { description },
33
40
  read: capability.read,
34
41
  write: capability.write,
42
+ create: capability.write && canCreate(entry),
35
43
  publish: capability.publish,
36
44
  drafts: entry.hasDrafts,
37
45
  draftValidation: hasDraftValidationEnabled(config),
@@ -42,7 +42,13 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
42
42
  const actual = pointer.get(saved);
43
43
  return survives(expected, actual) ? [] : [operation.path];
44
44
  });
45
- const patchDocument = defineMcpxTool({
45
+ /**
46
+ * The handler validates the whole batch against the schema and the current
47
+ * document before it writes anything, runs the write in a transaction, then
48
+ * re-reads the saved document to report which pointers survived and what still
49
+ * blocks publishing. Nothing here decides where the write lands: the draft
50
+ * guard does that on the Payload operation.
51
+ */ const patchDocument = defineMcpxTool({
46
52
  name: "patchDocument",
47
53
  description: DESCRIPTION,
48
54
  annotations: {
@@ -5,7 +5,11 @@ import { defineMcpxTool } from "../types.mjs";
5
5
  import { withTransaction } from "../write/transaction.mjs";
6
6
  import { withPublishIntent } from "../write/publish-intent.mjs";
7
7
  import { z } from "zod";
8
- const publishDocument = defineMcpxTool({
8
+ /**
9
+ * The only tool that changes live content, available where the config sets
10
+ * `write: "live"` on a versioned entity and the key has both the `write` and
11
+ * `publish` checkboxes.
12
+ */ const publishDocument = defineMcpxTool({
9
13
  name: "publishDocument",
10
14
  description: `Publishes the current draft, which changes what the public sees. This is the only tool that does; every other write lands as a draft. Call validateDocument first: a document that still has publish blockers is refused, and nothing is written.
11
15
 
@@ -40,7 +44,7 @@ There is no unpublish: reverting to a draft stays a human action in the admin pa
40
44
  });
41
45
  if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again before publishing.", { updatedAt: doc["updatedAt"] });
42
46
  const write = {
43
- data: {},
47
+ data: withPublishIntent({}),
44
48
  depth: 0,
45
49
  draft: false,
46
50
  fallbackLocale: false,
@@ -48,20 +52,14 @@ There is no unpublish: reverting to a draft stays a human action in the admin pa
48
52
  req: scope.req,
49
53
  ...locale === void 0 ? {} : { locale }
50
54
  };
51
- await withPublishIntent({
52
- kind: target.kind,
53
- slug: target.slug,
55
+ if (target.kind === "collection") await payload.update({
56
+ ...write,
57
+ collection: target.slug,
54
58
  id
55
- }, async () => {
56
- if (target.kind === "collection") await payload.update({
57
- ...write,
58
- collection: target.slug,
59
- id
60
- });
61
- else await payload.updateGlobal({
62
- ...write,
63
- slug: target.slug
64
- });
59
+ });
60
+ else await payload.updateGlobal({
61
+ ...write,
62
+ slug: target.slug
65
63
  });
66
64
  const saved = await readTarget(scope, {
67
65
  target,
@@ -1,10 +1,13 @@
1
- import { canPublish, isLiveWrite } from "../capabilities.mjs";
1
+ import { canCreate, canPublish, isLiveWrite } from "../capabilities.mjs";
2
2
  import { translateStatic } from "../i18n.mjs";
3
3
  import { NotFound } from "payload";
4
4
  import { z } from "zod";
5
5
  //#region src/tools/shared.ts
6
- const slugEnum = (slugs) => z.enum(slugs);
7
- const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
6
+ /**
7
+ * An out-of-scope slug fails schema validation before a handler runs, so a
8
+ * client only ever sees what its key may touch.
9
+ */ const slugEnum = (slugs) => z.enum(slugs);
10
+ /** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
8
11
  const slugsWhere = (scope, predicate, allowed) => {
9
12
  const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
10
13
  return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
@@ -17,33 +20,38 @@ const slugsWhere = (scope, predicate, allowed) => {
17
20
  collections: scope.writable,
18
21
  globals: scope.writableGlobals
19
22
  });
23
+ /**
24
+ * Slugs this key may write but never create in, because their documents are
25
+ * files. Collection-only, since nothing creates a global either way.
26
+ */ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
27
+ collections: scope.writable,
28
+ globals: []
29
+ });
20
30
  /** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
21
31
  collections: scope.publishable,
22
32
  globals: scope.publishableGlobals
23
33
  });
24
34
  /**
25
- * The sentence the write tools and the server instructions end on: what a write
26
- * actually does for this key, and what it takes to make it public. The three
27
- * groups are distinct — a live-write slug has no draft and no publish step, a
28
- * publishable one has both — so a client is never told its writes are drafts
29
- * while they are not, nor that publishing is out of reach when it is not.
35
+ * What a write actually does for this key, and what it takes to make it public.
36
+ * A live-write slug has no draft and no publish step; a publishable one has
37
+ * both. Stated per key so a client is never told its writes are drafts while
38
+ * they are not, nor that publishing is out of reach when it is not.
30
39
  */ const draftSentence = (scope) => {
31
40
  const live = liveWriteSlugs(scope);
32
41
  const publishable = publishableWriteSlugs(scope);
33
42
  return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
34
43
  };
35
- /**
36
- * Whether two timestamps name the same instant, which is how
37
- * `expectedUpdatedAt` is compared: the value a client read back is a string,
38
- * and what it is compared against may be a Date.
39
- */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
40
- /**
41
- * Widens one branch to the superset a handler sees. The widening itself is
42
- * unchecked — the runtime shape really does vary — so `Branch` checks what it
43
- * can around it.
44
- */ const widen = (branch) => branch;
45
- const slugsFor = (scope, operation) => {
44
+ /** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
45
+ /** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
46
+ /** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
46
47
  switch (operation) {
48
+ case "create": return {
49
+ collections: slugsWhere(scope, canCreate, {
50
+ collections: scope.writable,
51
+ globals: []
52
+ }),
53
+ globals: []
54
+ };
47
55
  case "publish": return {
48
56
  collections: scope.publishable,
49
57
  globals: scope.publishableGlobals
@@ -59,13 +67,9 @@ const slugsFor = (scope, operation) => {
59
67
  }
60
68
  };
61
69
  /**
62
- * The `collection` and `global` arguments.
63
- *
64
- * When the key can reach no global, `global` is left out of the shape entirely
65
- * and `collection` stays required, mirroring how {@link localeShape} omits
66
- * `locale` when localization is off. A deployment without globals therefore
67
- * sees exactly the schema it saw before. Only the mixed case makes either
68
- * argument optional, and the handler enforces the exclusivity there.
70
+ * With no reachable global, `global` is left out and `collection` stays
71
+ * required, so a deployment without globals sees an unchanged schema. Only the
72
+ * mixed case makes either optional, and the handler enforces exclusivity there.
69
73
  */ const targetShape = (scope, operation, descriptions) => {
70
74
  const { collections, globals } = slugsFor(scope, operation);
71
75
  if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
@@ -76,23 +80,23 @@ const slugsFor = (scope, operation) => {
76
80
  });
77
81
  };
78
82
  /**
79
- * The `id` argument, which only a collection document has. Omitted when the key
80
- * can reach no collection, required when it can reach no global, and optional
81
- * in between, where `requireIdFor` enforces the dependency.
83
+ * Only a collection document has one. Optional in the mixed case, where
84
+ * `requireIdFor` enforces the dependency.
82
85
  */ const idShape = (scope, operation) => {
83
86
  const { collections, globals } = slugsFor(scope, operation);
84
87
  if (collections.length === 0) return widen({});
85
88
  if (globals.length === 0) return widen({ id: idSchema });
86
89
  return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
87
90
  };
88
- /**
89
- * The `locale` argument, present only when localization is configured.
90
- */ const localeShape = (scope, options) => {
91
+ const localeShape = (scope, options) => {
91
92
  if (!scope.locales) return widen({});
92
93
  const locale = z.enum(scope.locales);
93
94
  return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
94
95
  };
95
- const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
96
+ /**
97
+ * Defaults to 0 rather than Payload's own default: a client usually wants ids
98
+ * it can write back, and populating a relation costs a query.
99
+ */ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
96
100
  /**
97
101
  * The locale to operate on: the explicit argument, else the request's, else
98
102
  * the default. `undefined` when localization is off.
@@ -133,9 +137,7 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
133
137
  slug: args.target.slug
134
138
  });
135
139
  };
136
- /**
137
- * Resolves a collection label for the request's language.
138
- */ const translateLabel = (scope, label, fallback) => {
140
+ const translateLabel = (scope, label, fallback) => {
139
141
  const { i18n, t } = scope.req;
140
142
  const resolved = typeof label === "function" ? label({
141
143
  i18n,
@@ -144,4 +146,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
144
146
  return translateStatic(resolved, i18n) ?? fallback;
145
147
  };
146
148
  //#endregion
147
- export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
149
+ export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, patchOnlySlugs, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
@@ -6,13 +6,9 @@ const refOf = (target) => ({
6
6
  slug: target.slug
7
7
  });
8
8
  /**
9
- * Resolves the `collection`/`global` arguments to one entity and checks the key
10
- * may perform `operation` on it.
11
- *
12
- * A tool's `inputSchema` returns a raw shape, which leaves no top-level
13
- * `.refine` to express "exactly one of collection and global". The rule is
14
- * enforced here instead, with a message naming the offending arguments so one
15
- * failed call teaches it.
9
+ * A raw input shape leaves no top-level `.refine` to express "exactly one of
10
+ * collection and global", so the rule is enforced here, with a message naming
11
+ * the offending arguments.
16
12
  */ const resolveTarget = (scope, args, operation) => {
17
13
  const { collection, global } = args;
18
14
  const allowedSlugs = slugsFor(scope, operation);
@@ -3,7 +3,15 @@ import { idShape, localeOf, localeShape, readTarget, targetShape } from "./share
3
3
  import { requireIdFor, resolveTarget } from "./target.mjs";
4
4
  import { defineMcpxTool } from "../types.mjs";
5
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
6
- const validateDocument = defineMcpxTool({
6
+ /**
7
+ * Gated on write rather than read, because publish blockers only mean
8
+ * something to a caller who can act on them.
9
+ *
10
+ * It reads the document twice on purpose: once under the key's own access to
11
+ * refuse a caller who may not see it, then privileged, so the check runs over
12
+ * every field rather than the subset the user can read. It carries no
13
+ * `readOnlyHint`, because the traversal fires field hooks.
14
+ */ const validateDocument = defineMcpxTool({
7
15
  name: "validateDocument",
8
16
  description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
9
17