@abinnovision/payloadcms-mcpx 1.0.0-beta.8 → 1.0.0
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/CHANGELOG.md +33 -0
- package/README.md +331 -194
- package/dist/api-keys/collection.mjs +3 -3
- package/dist/api-keys/fields.mjs +56 -5
- package/dist/api-keys/setup-guide.mjs +56 -0
- package/dist/auth/resolve.mjs +5 -7
- package/dist/capabilities.mjs +23 -4
- package/dist/client/index.d.mts +2 -0
- package/dist/client/index.mjs +2 -0
- package/dist/client/setup-guide.d.mts +14 -0
- package/dist/client/setup-guide.mjs +87 -0
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
- package/dist/endpoint/handler.mjs +11 -5
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +18 -26
- package/dist/i18n.mjs +4 -15
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -3
- package/dist/options.mjs +31 -26
- package/dist/plugin.mjs +1 -0
- package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
- package/dist/request.mjs +8 -0
- package/dist/result.d.mts +11 -0
- package/dist/result.mjs +20 -0
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +8 -0
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +195 -27
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +77 -30
- package/dist/schema/shape.mjs +133 -51
- package/dist/schema/walk.mjs +44 -64
- package/dist/tools/{index.mjs → builtin.mjs} +8 -5
- package/dist/tools/create-document.mjs +34 -15
- package/dist/tools/describe-schema.mjs +21 -7
- package/dist/tools/find-documents.mjs +13 -6
- package/dist/tools/get-document.mjs +45 -11
- package/dist/tools/list-capabilities.mjs +19 -9
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +32 -21
- package/dist/tools/publish-document.mjs +79 -0
- package/dist/tools/shared.mjs +84 -32
- package/dist/tools/target.mjs +7 -11
- package/dist/tools/validate-document.mjs +20 -12
- package/dist/types.d.mts +115 -42
- package/dist/types.mjs +3 -4
- package/dist/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +47 -44
- package/dist/write/patch.mjs +174 -92
- package/dist/write/publish-blockers.mjs +13 -12
- package/dist/write/publish-intent.mjs +17 -0
- package/dist/write/transaction.mjs +8 -3
- package/package.json +24 -9
- package/dist/i18n.d.mts +0 -1
- package/dist/options.d.mts +0 -2
- package/dist/schema/lexical.d.mts +0 -1
- package/dist/schema/walk.d.mts +0 -3
- package/dist/tools/target.d.mts +0 -3
- package/dist/tools/types.d.mts +0 -5
- package/dist/write/publish-blockers.d.mts +0 -15
|
@@ -4,12 +4,14 @@ import { findDocuments } from "./find-documents.mjs";
|
|
|
4
4
|
import { getDocument } from "./get-document.mjs";
|
|
5
5
|
import { listCapabilities } from "./list-capabilities.mjs";
|
|
6
6
|
import { patchDocument } from "./patch-document.mjs";
|
|
7
|
+
import { publishDocument } from "./publish-document.mjs";
|
|
7
8
|
import { validateDocument } from "./validate-document.mjs";
|
|
8
|
-
//#region src/tools/
|
|
9
|
+
//#region src/tools/builtin.ts
|
|
9
10
|
/**
|
|
10
|
-
* The builtin tools in registration order.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
13
15
|
*/ const BUILTIN_TOOLS = [
|
|
14
16
|
listCapabilities,
|
|
15
17
|
describeSchema,
|
|
@@ -17,7 +19,8 @@ import { validateDocument } from "./validate-document.mjs";
|
|
|
17
19
|
getDocument,
|
|
18
20
|
patchDocument,
|
|
19
21
|
createDocument,
|
|
20
|
-
validateDocument
|
|
22
|
+
validateDocument,
|
|
23
|
+
publishDocument
|
|
21
24
|
];
|
|
22
25
|
//#endregion
|
|
23
26
|
export { BUILTIN_TOOLS };
|
|
@@ -1,45 +1,63 @@
|
|
|
1
|
-
import { errorResult, jsonResult } from "../
|
|
2
|
-
import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
|
|
3
|
-
import { resolveTarget } from "./target.mjs";
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
4
2
|
import { validateWriteValue } from "../schema/shape.mjs";
|
|
3
|
+
import "../schema/index.mjs";
|
|
4
|
+
import { draftSentence, localeOf, localeShape, patchOnlySlugs, readTarget, slugEnum, slugsFor } from "./shared.mjs";
|
|
5
|
+
import { resolveTarget } from "./target.mjs";
|
|
6
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
5
7
|
import { stripRowIds } from "../write/patch.mjs";
|
|
6
8
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
7
9
|
import { z } from "zod";
|
|
8
10
|
//#region src/tools/create-document.ts
|
|
9
|
-
const
|
|
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
|
+
};
|
|
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.
|
|
16
|
+
|
|
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({
|
|
10
28
|
name: "createDocument",
|
|
11
|
-
description:
|
|
29
|
+
description: DESCRIPTION,
|
|
12
30
|
annotations: {
|
|
13
31
|
readOnlyHint: false,
|
|
14
32
|
destructiveHint: false,
|
|
15
33
|
idempotentHint: false,
|
|
16
34
|
openWorldHint: false
|
|
17
35
|
},
|
|
18
|
-
isEnabled: (scope) => scope.
|
|
36
|
+
isEnabled: (scope) => slugsFor(scope, "create").collections.length > 0,
|
|
19
37
|
inputSchema: (scope) => ({
|
|
20
|
-
collection: slugEnum(scope.
|
|
38
|
+
collection: slugEnum(slugsFor(scope, "create").collections).describe("Collection to create the document in."),
|
|
21
39
|
...localeShape(scope, {
|
|
22
40
|
required: true,
|
|
23
41
|
description: "Locale the localized fields of the seed belong to."
|
|
24
42
|
}),
|
|
25
43
|
data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
|
|
26
44
|
}),
|
|
27
|
-
handler: async (args, scope) => {
|
|
28
|
-
const target = resolveTarget(scope, { collection: args.collection }, "
|
|
45
|
+
handler: async ({ args, scope }) => {
|
|
46
|
+
const target = resolveTarget(scope, { collection: args.collection }, "create");
|
|
29
47
|
const { payload } = scope.req;
|
|
30
48
|
const locale = localeOf(scope, args.locale);
|
|
31
|
-
|
|
49
|
+
if ("id" in args.data) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
|
|
32
50
|
const problems = validateWriteValue(payload.config, {
|
|
33
51
|
pointer: "",
|
|
34
52
|
resolution: {
|
|
35
53
|
fields: target.config.flattenedFields,
|
|
36
54
|
prefix: []
|
|
37
55
|
}
|
|
38
|
-
},
|
|
56
|
+
}, args.data);
|
|
39
57
|
if (problems.length > 0) return errorResult("Nothing was created.", { problems });
|
|
40
58
|
const created = await payload.create({
|
|
41
59
|
collection: args.collection,
|
|
42
|
-
data: stripRowIds(
|
|
60
|
+
data: stripRowIds(args.data),
|
|
43
61
|
depth: 0,
|
|
44
62
|
draft: true,
|
|
45
63
|
overrideAccess: false,
|
|
@@ -52,7 +70,7 @@ const createDocument = {
|
|
|
52
70
|
locale,
|
|
53
71
|
privileged: true
|
|
54
72
|
});
|
|
55
|
-
const
|
|
73
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
56
74
|
doc: saved,
|
|
57
75
|
entity: target
|
|
58
76
|
});
|
|
@@ -60,9 +78,10 @@ const createDocument = {
|
|
|
60
78
|
id: saved["id"],
|
|
61
79
|
status: saved["_status"],
|
|
62
80
|
updatedAt: saved["updatedAt"],
|
|
63
|
-
...
|
|
81
|
+
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
82
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {}
|
|
64
83
|
});
|
|
65
84
|
}
|
|
66
|
-
};
|
|
85
|
+
});
|
|
67
86
|
//#endregion
|
|
68
87
|
export { createDocument };
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
2
|
+
import { nodePropertiesFor } from "../schema/lexical.mjs";
|
|
1
3
|
import { translatorFor } from "../i18n.mjs";
|
|
2
|
-
import {
|
|
4
|
+
import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
|
|
5
|
+
import "../schema/index.mjs";
|
|
3
6
|
import { targetShape } from "./shared.mjs";
|
|
4
7
|
import { refOf, resolveTarget } from "./target.mjs";
|
|
5
|
-
import {
|
|
8
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
6
9
|
import { z } from "zod";
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Describes each requested path independently and returns a per
|
|
12
|
+
* path error object instead of failing the call, so a client exploring several
|
|
13
|
+
* branches at once keeps the nodes that did resolve. `expand` swaps the
|
|
14
|
+
* requested paths for every node reachable from the root and appends a
|
|
15
|
+
* truncation notice past {@link REACHABLE_PATHS_LIMIT}.
|
|
16
|
+
*/ const describeSchema = defineMcpxTool({
|
|
9
17
|
name: "describeSchema",
|
|
10
18
|
description: `Describes the writable shape of a document, one node at a time.
|
|
11
19
|
|
|
@@ -15,7 +23,11 @@ Call it with no "paths" to get a collection's own fields. Every "blocks" field s
|
|
|
15
23
|
|
|
16
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.
|
|
17
25
|
|
|
18
|
-
|
|
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.
|
|
27
|
+
|
|
28
|
+
A rich text field's value is addressable too, so an edit does not have to rewrite the whole state: "/content/root/children/0" is the first top-level node, "/content/root/children/0/children/1" a node inside it, "/content/root/children/0/tag" one property of a node, and "/content/root/children/0/fields/url" a field of a node, described at the "next" path for that node type. Append a node with "/-". Which node sits at an index is only knowable from what is stored, so read it first: getDocument with "outline" answers with the pointer, type, "version" and a text excerpt for every node, which is far cheaper than reading the whole state.
|
|
29
|
+
|
|
30
|
+
Paths here use the same JSON Pointer syntax as getDocument and patchDocument, and are already resolved through anything that does not nest in the stored document. The difference is only what stands in an element position: a path names an array element "*" and a block by its slug, where a pointer into a document carries a 0-based index. So "/items/*/title" is written at "/items/0/title", and "/layout/sections/hero" at "/layout/sections/0". Inside a rich text field that substitution does not apply: a path there names the node type, and a block node its slug, where a pointer enters the stored state at "root" and walks "children" by an index counted over every child at that level, not over the blocks among them, with the node's own fields under "fields". So "/content/block/practice-note/variant" is written at "/content/root/children/7/fields/variant".
|
|
19
31
|
|
|
20
32
|
Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
|
|
21
33
|
annotations: {
|
|
@@ -31,7 +43,7 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
|
|
|
31
43
|
paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"/layout/sections/sectionWrapper\". Omit for the collection root."),
|
|
32
44
|
expand: z.boolean().optional().describe("Return every node reachable from the root in one response. Ignores paths.")
|
|
33
45
|
}),
|
|
34
|
-
handler: (args, scope) => {
|
|
46
|
+
handler: ({ args, scope }) => {
|
|
35
47
|
const ref = refOf(resolveTarget(scope, args, "read"));
|
|
36
48
|
const { config } = scope.req.payload;
|
|
37
49
|
const describeNode = nodeDescriber(translatorFor(scope.req.i18n));
|
|
@@ -46,9 +58,11 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
|
|
|
46
58
|
};
|
|
47
59
|
}
|
|
48
60
|
});
|
|
61
|
+
const nodeTypes = nodes.flatMap((node) => (node.fields ?? []).flatMap((field) => field.nodes ?? []));
|
|
62
|
+
if (nodeTypes.length > 0) nodes.push({ nodeProperties: nodePropertiesFor(nodeTypes) });
|
|
49
63
|
if (expanded?.truncated) nodes.push({ error: `Result truncated after ${String(400)} nodes. Request explicit paths instead.` });
|
|
50
64
|
return Promise.resolve(jsonResult(nodes));
|
|
51
65
|
}
|
|
52
|
-
};
|
|
66
|
+
});
|
|
53
67
|
//#endregion
|
|
54
68
|
export { describeSchema };
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { jsonResult } from "../
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
2
2
|
import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
|
|
3
3
|
import { resolveTarget } from "./target.mjs";
|
|
4
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
4
5
|
import { z } from "zod";
|
|
5
|
-
|
|
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: {
|
|
@@ -15,7 +22,7 @@ const findDocuments = {
|
|
|
15
22
|
collection: slugEnum(scope.readable).describe("Collection to search."),
|
|
16
23
|
where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
|
|
17
24
|
sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
|
|
18
|
-
limit: z.number().int().min(1).max(scope.
|
|
25
|
+
limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.limits.maxLimit)}.`),
|
|
19
26
|
page: z.number().int().min(1).optional().describe("Page number, from 1."),
|
|
20
27
|
...depthShape(scope),
|
|
21
28
|
select: z.record(z.string(), z.unknown()).optional().describe("Fields to return, e.g. {\"title\":true}."),
|
|
@@ -25,7 +32,7 @@ const findDocuments = {
|
|
|
25
32
|
}),
|
|
26
33
|
draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
|
|
27
34
|
}),
|
|
28
|
-
handler: async (args, scope) => {
|
|
35
|
+
handler: async ({ args, scope }) => {
|
|
29
36
|
resolveTarget(scope, { collection: args.collection }, "read");
|
|
30
37
|
const locale = localeOf(scope, args.locale);
|
|
31
38
|
const result = await scope.req.payload.find({
|
|
@@ -50,6 +57,6 @@ const findDocuments = {
|
|
|
50
57
|
hasNextPage: result.hasNextPage
|
|
51
58
|
});
|
|
52
59
|
}
|
|
53
|
-
};
|
|
60
|
+
});
|
|
54
61
|
//#endregion
|
|
55
62
|
export { findDocuments };
|
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
+
import { JSON_POINTER_PATTERN, findRichTextField, splitPath } from "../schema/walk.mjs";
|
|
3
|
+
import { lexicalOutline } from "../schema/outline.mjs";
|
|
4
|
+
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
5
|
+
import "../schema/index.mjs";
|
|
3
6
|
import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
|
|
4
|
-
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
7
|
+
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
8
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
5
9
|
import { z } from "zod";
|
|
6
10
|
import { Pointer } from "rfc6902";
|
|
7
11
|
//#region src/tools/get-document.ts
|
|
8
|
-
const
|
|
12
|
+
const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
|
|
13
|
+
/**
|
|
14
|
+
* With `path` the handler returns the subtree plus the `id`, `_status` and
|
|
15
|
+
* `updatedAt` a client needs to write back, so a caller reading one branch
|
|
16
|
+
* still gets the timestamp `expectedUpdatedAt` wants without a second call.
|
|
17
|
+
*/ const getDocument = defineMcpxTool({
|
|
9
18
|
name: "getDocument",
|
|
10
19
|
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.
|
|
11
20
|
|
|
12
|
-
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton
|
|
21
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
22
|
+
|
|
23
|
+
Set "outline" on a rich text "path" to get a compact positional listing of its nodes instead of the raw editor state.`,
|
|
13
24
|
annotations: {
|
|
14
25
|
readOnlyHint: true,
|
|
15
26
|
openWorldHint: false
|
|
@@ -27,9 +38,10 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
27
38
|
required: false,
|
|
28
39
|
description: "Locale to read. Defaults to the default locale."
|
|
29
40
|
}),
|
|
30
|
-
draft: z.boolean().optional().describe("Return the latest draft. Default true.")
|
|
41
|
+
draft: z.boolean().optional().describe("Return the latest draft. Default true."),
|
|
42
|
+
outline: z.boolean().optional().describe("For a rich text field, return a compact positional outline instead of the editor state. Requires \"path\".")
|
|
31
43
|
}),
|
|
32
|
-
handler: async (args, scope) => {
|
|
44
|
+
handler: async ({ args, scope }) => {
|
|
33
45
|
const target = resolveTarget(scope, args, "read");
|
|
34
46
|
const id = requireIdFor(target, args.id);
|
|
35
47
|
const locale = localeOf(scope, args.locale);
|
|
@@ -48,21 +60,43 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
48
60
|
...shared,
|
|
49
61
|
slug: target.slug
|
|
50
62
|
}));
|
|
51
|
-
if (args.path === void 0 || args.path === "")
|
|
63
|
+
if (args.path === void 0 || args.path === "") {
|
|
64
|
+
if (args.outline) return errorResult(OUTLINE_ERROR);
|
|
65
|
+
return jsonResult(doc);
|
|
66
|
+
}
|
|
52
67
|
let value;
|
|
53
68
|
try {
|
|
54
69
|
value = Pointer.fromJSON(args.path).get(doc);
|
|
55
70
|
} catch {
|
|
56
71
|
return errorResult(`"${args.path}" is not a valid JSON pointer.`);
|
|
57
72
|
}
|
|
58
|
-
|
|
73
|
+
const envelope = {
|
|
59
74
|
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
60
75
|
status: doc["_status"],
|
|
61
76
|
updatedAt: doc["updatedAt"],
|
|
62
|
-
path: args.path
|
|
77
|
+
path: args.path
|
|
78
|
+
};
|
|
79
|
+
if (!args.outline) return jsonResult({
|
|
80
|
+
...envelope,
|
|
63
81
|
value
|
|
64
82
|
});
|
|
83
|
+
let resolution;
|
|
84
|
+
try {
|
|
85
|
+
resolution = resolveDataPointer(scope.req.payload.config, {
|
|
86
|
+
doc,
|
|
87
|
+
pointer: args.path,
|
|
88
|
+
ref: refOf(target)
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return errorResult(error instanceof Error ? error.message : OUTLINE_ERROR);
|
|
92
|
+
}
|
|
93
|
+
const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
|
|
94
|
+
if (!field) return errorResult(OUTLINE_ERROR);
|
|
95
|
+
return jsonResult({
|
|
96
|
+
...envelope,
|
|
97
|
+
outline: lexicalOutline(value, args.path, field)
|
|
98
|
+
});
|
|
65
99
|
}
|
|
66
|
-
};
|
|
100
|
+
});
|
|
67
101
|
//#endregion
|
|
68
102
|
export { getDocument };
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
import { canCreate } from "../capabilities.mjs";
|
|
2
|
+
import { jsonResult } from "../result.mjs";
|
|
1
3
|
import { translatorFor } from "../i18n.mjs";
|
|
2
|
-
import { jsonResult } from "../endpoint/result.mjs";
|
|
3
4
|
import { translateLabel } from "./shared.mjs";
|
|
5
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
4
6
|
import { hasDraftValidationEnabled } from "payload/shared";
|
|
5
|
-
|
|
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: {
|
|
@@ -14,10 +21,10 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
14
21
|
},
|
|
15
22
|
isEnabled: () => true,
|
|
16
23
|
inputSchema: () => ({}),
|
|
17
|
-
handler: (
|
|
24
|
+
handler: ({ scope }) => {
|
|
18
25
|
const { payload } = scope.req;
|
|
19
26
|
const translate = translatorFor(scope.req.i18n);
|
|
20
|
-
const collections = scope.
|
|
27
|
+
const collections = scope.exposure.collections.flatMap((entry) => {
|
|
21
28
|
const capability = scope.capabilities.collections[entry.slug];
|
|
22
29
|
const collection = payload.collections[entry.slug];
|
|
23
30
|
if (!capability || !collection || !(capability.read || capability.write)) return [];
|
|
@@ -32,12 +39,14 @@ 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),
|
|
43
|
+
publish: capability.publish,
|
|
35
44
|
drafts: entry.hasDrafts,
|
|
36
45
|
draftValidation: hasDraftValidationEnabled(config),
|
|
37
46
|
idType: collection.customIDType ?? payload.db.defaultIDType
|
|
38
47
|
}];
|
|
39
48
|
});
|
|
40
|
-
const globals = scope.
|
|
49
|
+
const globals = scope.exposure.globals.flatMap((entry) => {
|
|
41
50
|
const capability = scope.capabilities.globals[entry.slug];
|
|
42
51
|
const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
|
|
43
52
|
if (!capability || !config || !(capability.read || capability.write)) return [];
|
|
@@ -48,6 +57,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
48
57
|
...description === void 0 ? {} : { description },
|
|
49
58
|
read: capability.read,
|
|
50
59
|
write: capability.write,
|
|
60
|
+
publish: capability.publish,
|
|
51
61
|
drafts: entry.hasDrafts,
|
|
52
62
|
draftValidation: hasDraftValidationEnabled(config)
|
|
53
63
|
}];
|
|
@@ -59,10 +69,10 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
59
69
|
codes: scope.locales,
|
|
60
70
|
default: scope.defaultLocale
|
|
61
71
|
} : null,
|
|
62
|
-
limits: scope.
|
|
72
|
+
limits: scope.limits,
|
|
63
73
|
tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
|
|
64
74
|
}));
|
|
65
75
|
}
|
|
66
|
-
};
|
|
76
|
+
});
|
|
67
77
|
//#endregion
|
|
68
78
|
export { listCapabilities };
|
package/dist/tools/names.mjs
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
|
-
import { errorResult, jsonResult } from "../
|
|
2
|
-
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
+
import { draftSentence, idShape, localeOf, localeShape, readTarget, sameInstant, targetShape } from "./shared.mjs";
|
|
3
3
|
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
5
|
+
import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
|
|
5
6
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
6
7
|
import { withTransaction } from "../write/transaction.mjs";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { Pointer } from "rfc6902";
|
|
9
10
|
//#region src/tools/patch-document.ts
|
|
10
|
-
const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
|
|
11
|
+
const DESCRIPTION = (scope) => `Applies RFC 6902 JSON Patch operations to one document.
|
|
11
12
|
|
|
12
13
|
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
${draftSentence(scope)}
|
|
15
16
|
|
|
16
|
-
Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
|
|
17
|
+
Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index. Inside a rich text field that substitution does not apply: a path there names the node type, and a block node its slug, where a pointer enters the stored state at "root" and walks "children" by an index counted over every child at that level, not over the blocks among them, with the node's own fields under "fields". So "/content/block/practice-note/variant" is written at "/content/root/children/7/fields/variant".
|
|
17
18
|
|
|
18
|
-
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so
|
|
19
|
+
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so an edit made since that read is refused rather than overwritten.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
Inside a rich text field a pointer keeps going: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties, and "/content/root/children/2/fields/url" a field it carries. A node written at a position must carry everything Lexical serializes, "version" included, exactly as one written inside a whole state must; getDocument with "outline" returns each node's pointer and version, which is the cheapest way to get both right. A node's "type" cannot be replaced on its own, and neither can the root.
|
|
22
|
+
|
|
23
|
+
Node positions shift as soon as anything is added or removed, so read immediately before patching, order removals from the last index to the first, and use a "test" operation on "/content/root/children/2/type" to assert a position is what you think it is before writing to it.
|
|
24
|
+
|
|
25
|
+
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but the document cannot be published until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
|
|
22
26
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
27
|
/**
|
|
24
28
|
* Whether the intended value survived the write. The saved document is
|
|
@@ -42,12 +46,18 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
42
46
|
const actual = pointer.get(saved);
|
|
43
47
|
return survives(expected, actual) ? [] : [operation.path];
|
|
44
48
|
});
|
|
45
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The handler validates the whole batch against the schema and the current
|
|
51
|
+
* document before it writes anything, runs the write in a transaction, then
|
|
52
|
+
* re-reads the saved document to report which pointers survived and what still
|
|
53
|
+
* blocks publishing. Nothing here decides where the write lands: the draft
|
|
54
|
+
* guard does that on the Payload operation.
|
|
55
|
+
*/ const patchDocument = defineMcpxTool({
|
|
46
56
|
name: "patchDocument",
|
|
47
57
|
description: DESCRIPTION,
|
|
48
58
|
annotations: {
|
|
49
59
|
readOnlyHint: false,
|
|
50
|
-
destructiveHint:
|
|
60
|
+
destructiveHint: true,
|
|
51
61
|
idempotentHint: false,
|
|
52
62
|
openWorldHint: false
|
|
53
63
|
},
|
|
@@ -63,13 +73,14 @@ const patchDocument = {
|
|
|
63
73
|
description: "Locale the patch applies to. Localized fields write here only."
|
|
64
74
|
}),
|
|
65
75
|
patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
|
|
66
|
-
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching.
|
|
76
|
+
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. Best effort: the write is refused if the document changed before the check, but not if it changes between the check and the write.")
|
|
67
77
|
}),
|
|
68
|
-
handler: async (args, scope) => {
|
|
78
|
+
handler: async ({ args, scope }) => {
|
|
69
79
|
const target = resolveTarget(scope, args, "write");
|
|
70
80
|
const id = requireIdFor(target, args.id);
|
|
71
81
|
const { payload } = scope.req;
|
|
72
82
|
const locale = localeOf(scope, args.locale);
|
|
83
|
+
const patches = args.patches;
|
|
73
84
|
return await withTransaction(scope.req, async () => {
|
|
74
85
|
const doc = await readTarget(scope, {
|
|
75
86
|
target,
|
|
@@ -77,13 +88,11 @@ const patchDocument = {
|
|
|
77
88
|
locale
|
|
78
89
|
});
|
|
79
90
|
if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again and re-apply the patch.", { updatedAt: doc["updatedAt"] });
|
|
80
|
-
const
|
|
91
|
+
const applied = applyPatchOperations(payload.config, {
|
|
81
92
|
doc,
|
|
82
|
-
patches
|
|
93
|
+
patches,
|
|
83
94
|
ref: refOf(target)
|
|
84
95
|
});
|
|
85
|
-
if (problems.length > 0) return errorResult("No operation was applied.", { problems });
|
|
86
|
-
const applied = applyPatchToCopy(doc, args.patches);
|
|
87
96
|
if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
|
|
88
97
|
const write = {
|
|
89
98
|
data: buildWriteData(payload.config, target.config, applied.next),
|
|
@@ -100,6 +109,7 @@ const patchDocument = {
|
|
|
100
109
|
});
|
|
101
110
|
else await payload.updateGlobal({
|
|
102
111
|
...write,
|
|
112
|
+
fallbackLocale: false,
|
|
103
113
|
slug: target.slug
|
|
104
114
|
});
|
|
105
115
|
const saved = await readTarget(scope, {
|
|
@@ -108,8 +118,8 @@ const patchDocument = {
|
|
|
108
118
|
locale,
|
|
109
119
|
privileged: true
|
|
110
120
|
});
|
|
111
|
-
const notApplied = notAppliedPointers(
|
|
112
|
-
const
|
|
121
|
+
const notApplied = notAppliedPointers(patches, applied.next, saved);
|
|
122
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
113
123
|
doc: saved,
|
|
114
124
|
entity: target
|
|
115
125
|
});
|
|
@@ -117,11 +127,12 @@ const patchDocument = {
|
|
|
117
127
|
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
118
128
|
status: saved["_status"],
|
|
119
129
|
updatedAt: saved["updatedAt"],
|
|
120
|
-
...
|
|
130
|
+
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
131
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {},
|
|
121
132
|
...notApplied.length > 0 ? { notApplied } : {}
|
|
122
133
|
});
|
|
123
134
|
});
|
|
124
135
|
}
|
|
125
|
-
};
|
|
136
|
+
});
|
|
126
137
|
//#endregion
|
|
127
138
|
export { patchDocument };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
+
import { idShape, localeOf, readTarget, sameInstant, targetShape } from "./shared.mjs";
|
|
3
|
+
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
5
|
+
import { withTransaction } from "../write/transaction.mjs";
|
|
6
|
+
import { withPublishIntent } from "../write/publish-intent.mjs";
|
|
7
|
+
import { z } from "zod";
|
|
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({
|
|
13
|
+
name: "publishDocument",
|
|
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.
|
|
15
|
+
|
|
16
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
17
|
+
|
|
18
|
+
The whole document is published, but Payload only validates the locale the publish runs in, so a required field left empty in another locale goes live empty. That is how the admin panel behaves too. Publishing is refused while a human holds the document open in the admin panel, and republishing an unchanged document is accepted but writes another version.
|
|
19
|
+
|
|
20
|
+
There is no unpublish: reverting to a draft stays a human action in the admin panel.`,
|
|
21
|
+
annotations: {
|
|
22
|
+
destructiveHint: true,
|
|
23
|
+
openWorldHint: false
|
|
24
|
+
},
|
|
25
|
+
isEnabled: (scope) => scope.publishable.length + scope.publishableGlobals.length > 0,
|
|
26
|
+
inputSchema: (scope) => ({
|
|
27
|
+
...targetShape(scope, "publish", {
|
|
28
|
+
collection: "Collection holding the document.",
|
|
29
|
+
global: "Global to publish."
|
|
30
|
+
}),
|
|
31
|
+
...idShape(scope, "publish"),
|
|
32
|
+
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before publishing. Best effort: the publish is refused if the document has changed since, but a write landing between the check and the publish is not.")
|
|
33
|
+
}),
|
|
34
|
+
handler: async ({ args, scope }) => {
|
|
35
|
+
const target = resolveTarget(scope, args, "publish");
|
|
36
|
+
const id = requireIdFor(target, args.id);
|
|
37
|
+
const { payload } = scope.req;
|
|
38
|
+
const locale = localeOf(scope, void 0);
|
|
39
|
+
return await withTransaction(scope.req, async () => {
|
|
40
|
+
const doc = await readTarget(scope, {
|
|
41
|
+
target,
|
|
42
|
+
id,
|
|
43
|
+
locale
|
|
44
|
+
});
|
|
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"] });
|
|
46
|
+
const write = {
|
|
47
|
+
data: withPublishIntent({}),
|
|
48
|
+
depth: 0,
|
|
49
|
+
draft: false,
|
|
50
|
+
fallbackLocale: false,
|
|
51
|
+
overrideAccess: false,
|
|
52
|
+
req: scope.req,
|
|
53
|
+
...locale === void 0 ? {} : { locale }
|
|
54
|
+
};
|
|
55
|
+
if (target.kind === "collection") await payload.update({
|
|
56
|
+
...write,
|
|
57
|
+
collection: target.slug,
|
|
58
|
+
id
|
|
59
|
+
});
|
|
60
|
+
else await payload.updateGlobal({
|
|
61
|
+
...write,
|
|
62
|
+
slug: target.slug
|
|
63
|
+
});
|
|
64
|
+
const saved = await readTarget(scope, {
|
|
65
|
+
target,
|
|
66
|
+
id,
|
|
67
|
+
locale,
|
|
68
|
+
privileged: true
|
|
69
|
+
});
|
|
70
|
+
return jsonResult({
|
|
71
|
+
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
72
|
+
status: saved["_status"],
|
|
73
|
+
updatedAt: saved["updatedAt"]
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
//#endregion
|
|
79
|
+
export { publishDocument };
|