@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.
- package/README.md +229 -166
- package/dist/api-keys/fields.mjs +7 -3
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +1 -3
- package/dist/capabilities.mjs +8 -8
- package/dist/endpoint/errors.mjs +0 -2
- package/dist/endpoint/server.mjs +6 -20
- package/dist/i18n.mjs +4 -15
- package/dist/options.mjs +7 -20
- package/dist/result.d.mts +3 -5
- package/dist/result.mjs +3 -5
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/lexical.mjs +160 -27
- package/dist/schema/pointer.mjs +5 -11
- package/dist/schema/shape.mjs +21 -19
- package/dist/schema/walk.mjs +36 -54
- package/dist/tools/builtin.mjs +4 -6
- package/dist/tools/create-document.mjs +19 -6
- package/dist/tools/describe-schema.mjs +9 -1
- package/dist/tools/find-documents.mjs +8 -1
- package/dist/tools/get-document.mjs +5 -1
- package/dist/tools/list-capabilities.mjs +10 -2
- package/dist/tools/patch-document.mjs +7 -1
- package/dist/tools/publish-document.mjs +13 -15
- package/dist/tools/shared.mjs +39 -37
- package/dist/tools/target.mjs +3 -7
- package/dist/tools/validate-document.mjs +9 -1
- package/dist/types.d.mts +38 -77
- package/dist/write/draft-guard.mjs +33 -65
- package/dist/write/patch.mjs +22 -60
- package/dist/write/publish-blockers.mjs +6 -12
- package/dist/write/publish-intent.mjs +13 -35
- package/dist/write/transaction.mjs +2 -3
- package/package.json +1 -1
package/dist/schema/walk.mjs
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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,
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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`
|
|
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
|
-
|
|
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;
|
package/dist/tools/builtin.mjs
CHANGED
|
@@ -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.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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.
|
|
36
|
+
isEnabled: (scope) => slugsFor(scope, "create").collections.length > 0,
|
|
24
37
|
inputSchema: (scope) => ({
|
|
25
|
-
collection: slugEnum(scope.
|
|
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 }, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
if (target.kind === "collection") await payload.update({
|
|
56
|
+
...write,
|
|
57
|
+
collection: target.slug,
|
|
54
58
|
id
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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
|
-
|
|
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 };
|
package/dist/tools/target.mjs
CHANGED
|
@@ -6,13 +6,9 @@ const refOf = (target) => ({
|
|
|
6
6
|
slug: target.slug
|
|
7
7
|
});
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
|