@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 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.
- package/README.md +141 -470
- package/dist/{client → admin}/capability-matrix.d.mts +3 -4
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +1 -1
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +6 -6
- package/dist/api-keys/capability-matrix.mjs +15 -85
- package/dist/api-keys/fields.mjs +15 -24
- package/dist/auth/resolve.mjs +23 -5
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
- package/dist/capabilities.mjs +34 -42
- package/dist/define-tool.d.mts +19 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +49 -19
- package/dist/endpoint/server.mjs +65 -19
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +5 -0
- package/dist/i18n.mjs +8 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +51 -45
- package/dist/plugin.mjs +0 -1
- package/dist/request.d.mts +2 -3
- package/dist/request.mjs +2 -3
- package/dist/result.d.mts +2 -3
- package/dist/result.mjs +3 -3
- package/dist/schema/admin-hidden.mjs +91 -0
- package/dist/schema/describe.mjs +17 -33
- package/dist/schema/errors.mjs +8 -0
- package/dist/schema/index.mjs +5 -2
- package/dist/schema/lexical-pointer.mjs +18 -20
- package/dist/schema/lexical.mjs +39 -92
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +22 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +35 -45
- package/dist/tools/builtin.mjs +4 -4
- package/dist/tools/create-document.mjs +26 -23
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +84 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +22 -18
- package/dist/tools/get-document.mjs +39 -32
- package/dist/tools/list-capabilities.mjs +23 -16
- package/dist/tools/patch-document.mjs +74 -50
- package/dist/tools/publish-document.mjs +17 -28
- package/dist/tools/query-paths.mjs +55 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +58 -99
- package/dist/tools/validate-document.mjs +16 -29
- package/dist/tools/versions.mjs +25 -17
- package/dist/types.d.mts +54 -56
- package/dist/write/draft-guard.mjs +17 -37
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +4 -4
- package/dist/write/row-ids.mjs +44 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +4 -4
- package/dist/client/capability-matrix.mjs +0 -349
- package/dist/endpoint/index.mjs +0 -4
- package/dist/tools/target.mjs +0 -45
- /package/dist/{client → admin}/index.d.mts +0 -0
- /package/dist/{client → admin}/index.mjs +0 -0
package/dist/schema/walk.mjs
CHANGED
|
@@ -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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
31
|
+
* `admin.hidden`, which its own upload base fields carry.
|
|
36
32
|
*/ const isAdminHidden = (field) => "admin" in field && field.admin.hidden === true;
|
|
37
|
-
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
132
|
-
*
|
|
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
|
|
136
|
-
* {@link
|
|
137
|
-
* undefined
|
|
138
|
-
*
|
|
139
|
-
*/ const
|
|
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 {
|
|
135
|
+
export { RESERVED_FIELD_NAMES, ROW_KEYS, blockOf, blockSlugsOf, describeAddressableFields, describeFields, descriptorsUnder, findBlocksField, findRichTextField, isAdminHidden, schemaOf };
|
package/dist/tools/builtin.mjs
CHANGED
|
@@ -9,10 +9,10 @@ import { publishDocument } from "./publish-document.mjs";
|
|
|
9
9
|
import { validateDocument } from "./validate-document.mjs";
|
|
10
10
|
//#region src/tools/builtin.ts
|
|
11
11
|
/**
|
|
12
|
-
* The builtin tools, in registration order.
|
|
13
|
-
* or field never changes
|
|
14
|
-
* `isEnabled`, which derives from the key's capabilities
|
|
15
|
-
* checkbox of
|
|
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.
|
|
16
16
|
*/ const BUILTIN_TOOLS = [
|
|
17
17
|
listCapabilities,
|
|
18
18
|
describeSchema,
|
|
@@ -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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
11
|
+
const uploadSentence = (scope) => {
|
|
12
12
|
const slugs = patchOnlySlugs(scope);
|
|
13
|
-
return slugs.length === 0 ? "" :
|
|
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
|
|
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
|
-
${
|
|
17
|
+
${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
|
|
18
18
|
/**
|
|
19
|
-
* Collection-only, because a global always exists
|
|
20
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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)
|
|
38
|
+
collection: slugEnum(slugsFor(scope, "create").collections),
|
|
39
39
|
...localeShape(scope, {
|
|
40
40
|
required: true,
|
|
41
|
-
description: "Locale the localized fields
|
|
41
|
+
description: "Locale of the localized fields in \"data\"."
|
|
42
42
|
}),
|
|
43
|
-
data: z.record(z.string(), z.unknown()).describe("
|
|
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 =
|
|
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"
|
|
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
|
|
68
|
-
target
|
|
69
|
-
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
33
|
+
isEnabled: (scope) => scope.collections.readable.length + scope.globals.readable.length > 0,
|
|
38
34
|
inputSchema: (scope) => ({
|
|
39
|
-
...
|
|
40
|
-
|
|
41
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
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 {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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)
|
|
23
|
-
where: z.record(z.string(), z.unknown()).optional()
|
|
24
|
-
sort: z.string().optional().describe("
|
|
25
|
-
limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe(
|
|
26
|
-
page: z.number().int().min(1).optional()
|
|
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:
|
|
35
|
+
description: READ_LOCALE_DESCRIPTION
|
|
32
36
|
}),
|
|
33
|
-
draft: z.boolean().optional().describe("
|
|
37
|
+
draft: z.boolean().optional().describe("Default true: latest drafts.")
|
|
34
38
|
}),
|
|
35
39
|
handler: async ({ args, scope }) => {
|
|
36
|
-
|
|
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
|
|
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,
|
|
@@ -1,18 +1,26 @@
|
|
|
1
|
+
import { defineMcpxTool } from "../define-tool.mjs";
|
|
1
2
|
import { jsonResult } from "../result.mjs";
|
|
2
|
-
import { idShape, localeOf, localeShape, slugsFor,
|
|
3
|
-
import {
|
|
4
|
-
import { defineMcpxTool } from "../types.mjs";
|
|
3
|
+
import { draftVersionSlugs, entityShape, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
|
|
4
|
+
import { resolveDocument } from "./document.mjs";
|
|
5
5
|
import { queryVersions } from "./versions.mjs";
|
|
6
|
+
import { APIError } from "payload";
|
|
7
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
6
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
|
+
};
|
|
7
16
|
/**
|
|
8
17
|
* Bodies are left out so a long history stays small; `getDocument` reads one
|
|
9
18
|
* when it is needed. The document's `read` access is checked first, then
|
|
10
|
-
* Payload's `readVersions`.
|
|
19
|
+
* Payload's `readVersions`. Only entities with `versions: true` in the plugin
|
|
20
|
+
* options are reachable.
|
|
11
21
|
*/ const findVersions = defineMcpxTool({
|
|
12
22
|
name: "findVersions",
|
|
13
|
-
description:
|
|
14
|
-
|
|
15
|
-
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global". Autosave versions are listed too, marked "autosave".`,
|
|
23
|
+
description: DESCRIPTION,
|
|
16
24
|
annotations: {
|
|
17
25
|
readOnlyHint: true,
|
|
18
26
|
openWorldHint: false
|
|
@@ -22,25 +30,21 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
22
30
|
return collections.length + globals.length > 0;
|
|
23
31
|
},
|
|
24
32
|
inputSchema: (scope) => ({
|
|
25
|
-
...
|
|
26
|
-
collection: "Collection holding the document.",
|
|
27
|
-
global: "Global whose history to list."
|
|
28
|
-
}),
|
|
33
|
+
...entityShape(scope, "versions"),
|
|
29
34
|
...idShape(scope, "versions"),
|
|
30
|
-
limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe(
|
|
31
|
-
page: z.number().int().min(1).optional()
|
|
32
|
-
|
|
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),
|
|
33
38
|
...localeShape(scope, {
|
|
34
39
|
required: false,
|
|
35
|
-
description: "Locale whose status
|
|
40
|
+
description: "Locale whose \"status\" is reported. Defaults to the default locale."
|
|
36
41
|
})
|
|
37
42
|
}),
|
|
38
43
|
handler: async ({ args, scope }) => {
|
|
39
|
-
const target =
|
|
40
|
-
|
|
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);
|
|
41
46
|
const result = await queryVersions(scope, {
|
|
42
47
|
target,
|
|
43
|
-
id,
|
|
44
48
|
depth: 0,
|
|
45
49
|
locale: localeOf(scope, args.locale)
|
|
46
50
|
}, {
|