@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.12
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 +144 -470
- package/dist/admin/capability-matrix.d.mts +33 -0
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +4 -2
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +12 -8
- package/dist/api-keys/capability-matrix.mjs +14 -86
- package/dist/api-keys/collection.mjs +2 -1
- package/dist/api-keys/fields.mjs +36 -24
- package/dist/auth/resolve.mjs +71 -23
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
- package/dist/capabilities.mjs +44 -46
- package/dist/define-tool.d.mts +23 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +73 -29
- package/dist/endpoint/server.mjs +85 -22
- package/dist/endpoint/upload.mjs +135 -0
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +7 -0
- package/dist/i18n.mjs +10 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +54 -47
- package/dist/plugin.d.mts +2 -2
- package/dist/plugin.mjs +13 -3
- 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 +44 -93
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +28 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +43 -45
- package/dist/tools/builtin.mjs +4 -4
- package/dist/tools/create-document.mjs +50 -24
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +124 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +22 -18
- package/dist/tools/get-document.mjs +60 -33
- package/dist/tools/list-capabilities.mjs +35 -19
- package/dist/tools/patch-document.mjs +122 -66
- package/dist/tools/publish-document.mjs +26 -32
- package/dist/tools/query-paths.mjs +57 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +74 -105
- package/dist/tools/validate-document.mjs +18 -30
- package/dist/tools/versions.mjs +37 -20
- package/dist/types.d.mts +135 -75
- package/dist/upload/file.mjs +166 -0
- package/dist/upload/grant.mjs +96 -0
- package/dist/version.mjs +3 -1
- package/dist/write/draft-guard.mjs +23 -41
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +6 -4
- package/dist/write/row-ids.mjs +46 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +11 -10
- package/dist/client/capability-matrix.d.mts +0 -26
- 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
|
@@ -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,124 @@
|
|
|
1
|
+
import { errorResult } from "../result.mjs";
|
|
2
|
+
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
3
|
+
import { slugsFor } from "./shared.mjs";
|
|
4
|
+
import { APIError, Forbidden, NotFound } from "payload";
|
|
5
|
+
//#region src/tools/document.ts
|
|
6
|
+
/**
|
|
7
|
+
* A raw input shape leaves no top-level `.refine` to express "exactly one of
|
|
8
|
+
* collection and global", so the rule is enforced here, with a message naming
|
|
9
|
+
* the offending arguments.
|
|
10
|
+
*/ const resolveEntity = (scope, args, operation) => {
|
|
11
|
+
const { collection, global } = args;
|
|
12
|
+
const allowedSlugs = slugsFor(scope, operation);
|
|
13
|
+
if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
|
|
14
|
+
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);
|
|
15
|
+
if (collection !== void 0) {
|
|
16
|
+
const found = scope.req.payload.collections[collection];
|
|
17
|
+
if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
|
|
18
|
+
return {
|
|
19
|
+
kind: "collection",
|
|
20
|
+
slug: collection,
|
|
21
|
+
config: found.config
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const slug = global;
|
|
25
|
+
const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
|
|
26
|
+
if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
|
|
27
|
+
return {
|
|
28
|
+
kind: "global",
|
|
29
|
+
slug,
|
|
30
|
+
config: found
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Resolves the entity and checks `id` against it. A collection document needs
|
|
35
|
+
* one; a global is a singleton and must not carry one. The schema cannot
|
|
36
|
+
* express that, so it is checked here and stated on the `id` parameter.
|
|
37
|
+
*/ const resolveDocument = (scope, args, operation) => {
|
|
38
|
+
const target = resolveEntity(scope, args, operation);
|
|
39
|
+
if (target.kind === "global") {
|
|
40
|
+
if (args.id !== void 0) throw new APIError(`"id" must be omitted when "global" is "${target.slug}"; a global is a singleton.`, 400);
|
|
41
|
+
return target;
|
|
42
|
+
}
|
|
43
|
+
if (args.id === void 0) throw new APIError(`"id" is required when "collection" is "${target.slug}".`, 400);
|
|
44
|
+
return {
|
|
45
|
+
...target,
|
|
46
|
+
id: args.id
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* What a response names its subject by: a document's id, or a global's slug.
|
|
51
|
+
*/ const identityOf = (target, id) => target.kind === "collection" ? { id } : { global: target.slug };
|
|
52
|
+
const sameInstant = (left, right) => (typeof left === "string" || left instanceof Date) && new Date(left).getTime() === new Date(right).getTime();
|
|
53
|
+
/**
|
|
54
|
+
* Refuses a write when the document changed since the client read it.
|
|
55
|
+
*/ const staleReadResult = (doc, expectedUpdatedAt, message) => expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], expectedUpdatedAt) ? errorResult(message, { updatedAt: doc["updatedAt"] }) : void 0;
|
|
56
|
+
/**
|
|
57
|
+
* Reads the current draft in a fixed locale with no fallback, which is the
|
|
58
|
+
* shape that may be written back or validated without mixing locales.
|
|
59
|
+
*/ const readDraft = async (scope, args) => {
|
|
60
|
+
const { payload } = scope.req;
|
|
61
|
+
const privileged = args.privileged === true;
|
|
62
|
+
const shared = {
|
|
63
|
+
depth: 0,
|
|
64
|
+
draft: true,
|
|
65
|
+
...args.locale === void 0 ? {} : {
|
|
66
|
+
locale: args.locale,
|
|
67
|
+
fallbackLocale: false
|
|
68
|
+
},
|
|
69
|
+
overrideAccess: privileged,
|
|
70
|
+
showHiddenFields: privileged,
|
|
71
|
+
req: scope.req
|
|
72
|
+
};
|
|
73
|
+
if (args.target.kind === "collection") {
|
|
74
|
+
const doc = await payload.findByID({
|
|
75
|
+
...shared,
|
|
76
|
+
collection: args.target.slug,
|
|
77
|
+
id: args.target.id,
|
|
78
|
+
disableErrors: true
|
|
79
|
+
});
|
|
80
|
+
if (!doc) throw new NotFound(scope.req.t);
|
|
81
|
+
return doc;
|
|
82
|
+
}
|
|
83
|
+
return await payload.findGlobal({
|
|
84
|
+
...shared,
|
|
85
|
+
slug: args.target.slug
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* {@link collectPublishBlockers} over the draft of each locale, each blocker
|
|
90
|
+
* tagged with its locale. Locales run in turn because hooks share `req`, and
|
|
91
|
+
* the request's locale and fallback locale are restored afterwards.
|
|
92
|
+
*/ const collectLocaleBlockers = async (scope, target, locales) => {
|
|
93
|
+
const { req } = scope;
|
|
94
|
+
const { fallbackLocale, locale: requestLocale } = req;
|
|
95
|
+
const blockers = [];
|
|
96
|
+
let unavailable = false;
|
|
97
|
+
for (const locale of locales) try {
|
|
98
|
+
const doc = await readDraft(scope, {
|
|
99
|
+
target,
|
|
100
|
+
locale,
|
|
101
|
+
privileged: true
|
|
102
|
+
});
|
|
103
|
+
const result = await collectPublishBlockers(req, {
|
|
104
|
+
doc,
|
|
105
|
+
entity: target
|
|
106
|
+
});
|
|
107
|
+
unavailable ||= result.unavailable === true;
|
|
108
|
+
blockers.push(...result.blockers.map((entry) => ({
|
|
109
|
+
...entry,
|
|
110
|
+
locale
|
|
111
|
+
})));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Could not read the ${target.slug} draft in ${locale}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
114
|
+
unavailable = true;
|
|
115
|
+
}
|
|
116
|
+
if (requestLocale !== void 0) req.locale = requestLocale;
|
|
117
|
+
if (fallbackLocale !== void 0) req.fallbackLocale = fallbackLocale;
|
|
118
|
+
return {
|
|
119
|
+
blockers,
|
|
120
|
+
...unavailable ? { unavailable: true } : {}
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
124
|
+
export { collectLocaleBlockers, 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 that keep Payload versions are
|
|
20
|
+
* 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
|
}, {
|
|
@@ -1,28 +1,32 @@
|
|
|
1
|
+
import { defineMcpxTool } from "../define-tool.mjs";
|
|
1
2
|
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
-
import { JSON_POINTER_PATTERN,
|
|
3
|
+
import { JSON_POINTER_PATTERN, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
|
|
4
|
+
import { findRichTextField } from "../schema/walk.mjs";
|
|
5
|
+
import { SchemaError } from "../schema/errors.mjs";
|
|
3
6
|
import { lexicalOutline } from "../schema/outline.mjs";
|
|
4
7
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
5
8
|
import "../schema/index.mjs";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
+
import { downloadHandoff, downloadSlugs } from "../upload/file.mjs";
|
|
10
|
+
import { READ_LOCALE_DESCRIPTION, depthShape, entityShape, idSchema, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
|
|
11
|
+
import { identityOf, resolveDocument } from "./document.mjs";
|
|
9
12
|
import { diffDocuments, loadPublished, loadVersion, readLive } from "./versions.mjs";
|
|
10
13
|
import { APIError } from "payload";
|
|
11
14
|
import { z } from "zod";
|
|
12
15
|
import { Pointer } from "rfc6902";
|
|
13
16
|
//#region src/tools/get-document.ts
|
|
14
17
|
const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
|
|
15
|
-
const
|
|
18
|
+
const VERSION_PARAGRAPH = `
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
"versionId" and "diffFrom" work where findVersions does. To revert, call getDocument with the old version as "versionId", the version findVersions marks "latest" as "diffFrom" and the locale you will write, then apply the returned "patch" with patchDocument.`;
|
|
21
|
+
const DESCRIPTION = `Reads one document or global, or with "path" only the value at that pointer. Returns the latest draft by default, with the "updatedAt" a write takes as "expectedUpdatedAt".`;
|
|
22
|
+
const exposesVersions = (scope) => {
|
|
23
|
+
const { collections, globals } = slugsFor(scope, "versions");
|
|
24
|
+
return collections.length + globals.length > 0;
|
|
25
|
+
};
|
|
26
|
+
const assertVersionArgs = (scope, target, args) => {
|
|
23
27
|
if (args.versionId === void 0 && args.diffFrom === void 0) return;
|
|
24
28
|
const { collections, globals } = slugsFor(scope, "versions");
|
|
25
|
-
if (!(target.kind === "collection" ? collections : globals).includes(target.slug)) throw new APIError(`"${target.slug}"
|
|
29
|
+
if (!(target.kind === "collection" ? collections : globals).includes(target.slug)) throw new APIError(`"${target.slug}" does not expose version history.`, 400);
|
|
26
30
|
if (args.versionId !== void 0 && args.draft !== void 0) throw new APIError("Pass either \"versionId\" or \"draft\", not both.", 400);
|
|
27
31
|
if (args.diffFrom !== void 0 && args.outline) throw new APIError("\"outline\" cannot be combined with \"diffFrom\".", 400);
|
|
28
32
|
};
|
|
@@ -36,39 +40,57 @@ const diffResult = async (scope, read, diff, doc) => {
|
|
|
36
40
|
patch: diffDocuments(from.version, doc, diff.pointer)
|
|
37
41
|
});
|
|
38
42
|
};
|
|
43
|
+
const versionShape = (scope) => {
|
|
44
|
+
if (!exposesVersions(scope)) return widen({});
|
|
45
|
+
return widen({
|
|
46
|
+
versionId: idSchema.optional().describe("From findVersions. Not with \"draft\"."),
|
|
47
|
+
diffFrom: idSchema.optional().describe("Version id, or \"published\" for the newest version published in any locale. Returns {from, to, patch}, the JSON Patch from that version to what is read.")
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
const downloadShape = (scope) => {
|
|
51
|
+
const slugs = downloadSlugs(scope);
|
|
52
|
+
return widen(slugs.length === 0 ? {} : { download: z.literal(true).optional().describe(`Only for ${slugs.join(", ")}. Adds "download": GET its "url" with its "headers" once, within 5 minutes, for the file. Not with "path", "versionId" or "diffFrom".`) });
|
|
53
|
+
};
|
|
54
|
+
const assertDownloadArgs = (scope, target, args) => {
|
|
55
|
+
if (target.kind === "global" || !downloadSlugs(scope).includes(target.slug)) throw new APIError(`"${target.slug}" has no file to download.`, 400);
|
|
56
|
+
if (args.path !== void 0 || args.versionId !== void 0 || args.diffFrom !== void 0) throw new APIError("\"download\" cannot be combined with \"path\", \"versionId\" or \"diffFrom\".", 400);
|
|
57
|
+
};
|
|
58
|
+
const withDownload = async (scope, doc, target) => typeof doc["filename"] === "string" ? jsonResult({
|
|
59
|
+
...doc,
|
|
60
|
+
download: await downloadHandoff(scope, target)
|
|
61
|
+
}) : errorResult("This document has no file to download.");
|
|
39
62
|
/**
|
|
40
63
|
* With `path` the handler returns the subtree plus the `id`, `_status` and
|
|
41
|
-
* `updatedAt` a client needs to write back, so
|
|
42
|
-
*
|
|
64
|
+
* `updatedAt` a client needs to write back, so reading one branch still gives
|
|
65
|
+
* the `expectedUpdatedAt` timestamp without a second call.
|
|
43
66
|
*/ const getDocument = defineMcpxTool({
|
|
44
67
|
name: "getDocument",
|
|
45
|
-
description: DESCRIPTION,
|
|
68
|
+
description: (scope) => exposesVersions(scope) ? DESCRIPTION + VERSION_PARAGRAPH : DESCRIPTION,
|
|
46
69
|
annotations: {
|
|
47
70
|
readOnlyHint: true,
|
|
48
71
|
openWorldHint: false
|
|
49
72
|
},
|
|
50
|
-
isEnabled: (scope) => scope.readable.length + scope.
|
|
73
|
+
isEnabled: (scope) => scope.collections.readable.length + scope.globals.readable.length > 0,
|
|
51
74
|
inputSchema: (scope) => ({
|
|
52
|
-
...
|
|
53
|
-
collection: "Collection holding the document.",
|
|
54
|
-
global: "Global to read."
|
|
55
|
-
}),
|
|
75
|
+
...entityShape(scope, "read"),
|
|
56
76
|
...idShape(scope, "read"),
|
|
57
|
-
path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("
|
|
77
|
+
path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("e.g. \"/layout/sections/0\"."),
|
|
58
78
|
...depthShape(scope),
|
|
59
79
|
...localeShape(scope, {
|
|
60
80
|
required: false,
|
|
61
|
-
description:
|
|
81
|
+
description: READ_LOCALE_DESCRIPTION
|
|
62
82
|
}),
|
|
63
|
-
draft: z.boolean().optional().describe("
|
|
64
|
-
outline: z.boolean().optional().describe("
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
draft: z.boolean().optional().describe("Default true. false reads the published version."),
|
|
84
|
+
outline: z.boolean().optional().describe("With \"path\" at a rich text field: list each node's pointer, type, \"version\" and text."),
|
|
85
|
+
...versionShape(scope),
|
|
86
|
+
...downloadShape(scope)
|
|
67
87
|
}),
|
|
68
88
|
handler: async ({ args, scope }) => {
|
|
69
|
-
const target =
|
|
70
|
-
const id = requireIdFor(target, args.id);
|
|
89
|
+
const target = resolveDocument(scope, args, "read");
|
|
71
90
|
assertVersionArgs(scope, target, args);
|
|
91
|
+
if (args.download) assertDownloadArgs(scope, target, args);
|
|
92
|
+
const prototyped = args.path ? prototypeSegmentProblem(args.path) : void 0;
|
|
93
|
+
if (prototyped !== void 0) return errorResult(prototyped);
|
|
72
94
|
let pointer;
|
|
73
95
|
try {
|
|
74
96
|
pointer = args.path ? Pointer.fromJSON(args.path) : void 0;
|
|
@@ -77,7 +99,6 @@ const diffResult = async (scope, read, diff, doc) => {
|
|
|
77
99
|
}
|
|
78
100
|
const read = {
|
|
79
101
|
target,
|
|
80
|
-
id,
|
|
81
102
|
depth: args.depth ?? 0,
|
|
82
103
|
locale: localeOf(scope, args.locale)
|
|
83
104
|
};
|
|
@@ -90,12 +111,17 @@ const diffResult = async (scope, read, diff, doc) => {
|
|
|
90
111
|
}, doc);
|
|
91
112
|
if (pointer === void 0) {
|
|
92
113
|
if (args.outline) return errorResult(OUTLINE_ERROR);
|
|
93
|
-
return
|
|
114
|
+
return args.download && args.id !== void 0 ? await withDownload(scope, doc, {
|
|
115
|
+
collection: target.slug,
|
|
116
|
+
id: args.id,
|
|
117
|
+
draft: args.draft ?? true,
|
|
118
|
+
locale: read.locale ?? null
|
|
119
|
+
}) : jsonResult(doc);
|
|
94
120
|
}
|
|
95
121
|
const path = pointer.toString();
|
|
96
122
|
const value = pointer.get(doc);
|
|
97
123
|
const envelope = {
|
|
98
|
-
...target.
|
|
124
|
+
...identityOf(target, args.id),
|
|
99
125
|
status: doc["_status"],
|
|
100
126
|
updatedAt: doc["updatedAt"],
|
|
101
127
|
path
|
|
@@ -109,10 +135,11 @@ const diffResult = async (scope, read, diff, doc) => {
|
|
|
109
135
|
resolution = resolveDataPointer(scope.req.payload.config, {
|
|
110
136
|
doc,
|
|
111
137
|
pointer: path,
|
|
112
|
-
ref:
|
|
138
|
+
ref: target
|
|
113
139
|
});
|
|
114
140
|
} catch (error) {
|
|
115
|
-
|
|
141
|
+
if (!(error instanceof SchemaError)) throw error;
|
|
142
|
+
return errorResult(error.message);
|
|
116
143
|
}
|
|
117
144
|
const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
|
|
118
145
|
if (!field) return errorResult(OUTLINE_ERROR);
|