@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.
Files changed (76) hide show
  1. package/README.md +144 -470
  2. package/dist/admin/capability-matrix.d.mts +33 -0
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +4 -2
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +12 -8
  8. package/dist/api-keys/capability-matrix.mjs +14 -86
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +71 -23
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
  13. package/dist/capabilities.mjs +44 -46
  14. package/dist/define-tool.d.mts +23 -0
  15. package/dist/{types.mjs → define-tool.mjs} +1 -1
  16. package/dist/endpoint/errors.mjs +5 -6
  17. package/dist/endpoint/handler.mjs +73 -29
  18. package/dist/endpoint/server.mjs +85 -22
  19. package/dist/endpoint/upload.mjs +135 -0
  20. package/dist/entity.d.mts +9 -0
  21. package/dist/guards.mjs +7 -0
  22. package/dist/i18n.mjs +10 -8
  23. package/dist/index.d.mts +5 -2
  24. package/dist/index.mjs +3 -2
  25. package/dist/options.mjs +54 -47
  26. package/dist/plugin.d.mts +2 -2
  27. package/dist/plugin.mjs +13 -3
  28. package/dist/request.d.mts +2 -3
  29. package/dist/request.mjs +2 -3
  30. package/dist/result.d.mts +2 -3
  31. package/dist/result.mjs +3 -3
  32. package/dist/schema/admin-hidden.mjs +91 -0
  33. package/dist/schema/describe.mjs +17 -33
  34. package/dist/schema/errors.mjs +8 -0
  35. package/dist/schema/index.mjs +5 -2
  36. package/dist/schema/lexical-pointer.mjs +18 -20
  37. package/dist/schema/lexical.mjs +44 -93
  38. package/dist/schema/outline.mjs +5 -15
  39. package/dist/schema/path.mjs +28 -0
  40. package/dist/schema/pointer.mjs +18 -25
  41. package/dist/schema/shape.mjs +25 -71
  42. package/dist/schema/walk.mjs +43 -45
  43. package/dist/tools/builtin.mjs +4 -4
  44. package/dist/tools/create-document.mjs +50 -24
  45. package/dist/tools/describe-schema.mjs +38 -34
  46. package/dist/tools/document.mjs +124 -0
  47. package/dist/tools/find-documents.mjs +32 -19
  48. package/dist/tools/find-versions.mjs +22 -18
  49. package/dist/tools/get-document.mjs +60 -33
  50. package/dist/tools/list-capabilities.mjs +35 -19
  51. package/dist/tools/patch-document.mjs +122 -66
  52. package/dist/tools/publish-document.mjs +26 -32
  53. package/dist/tools/query-paths.mjs +57 -0
  54. package/dist/tools/read-request.d.mts +10 -0
  55. package/dist/tools/read-request.mjs +43 -0
  56. package/dist/tools/shared.mjs +74 -105
  57. package/dist/tools/validate-document.mjs +18 -30
  58. package/dist/tools/versions.mjs +37 -20
  59. package/dist/types.d.mts +135 -75
  60. package/dist/upload/file.mjs +166 -0
  61. package/dist/upload/grant.mjs +96 -0
  62. package/dist/version.mjs +3 -1
  63. package/dist/write/draft-guard.mjs +23 -41
  64. package/dist/write/patch.mjs +27 -183
  65. package/dist/write/publish-blockers.mjs +7 -14
  66. package/dist/write/publish-intent.mjs +6 -4
  67. package/dist/write/row-ids.mjs +46 -0
  68. package/dist/write/transaction.mjs +5 -8
  69. package/dist/write/write-data.mjs +64 -0
  70. package/package.json +11 -10
  71. package/dist/client/capability-matrix.d.mts +0 -26
  72. package/dist/client/capability-matrix.mjs +0 -349
  73. package/dist/endpoint/index.mjs +0 -4
  74. package/dist/tools/target.mjs +0 -45
  75. /package/dist/{client → admin}/index.d.mts +0 -0
  76. /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 { targetShape } from "./shared.mjs";
7
- import { refOf, resolveTarget } from "./target.mjs";
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
- * 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({
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
- 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.
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
- 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".
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
- Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
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.readableGlobals.length > 0,
33
+ isEnabled: (scope) => scope.collections.readable.length + scope.globals.readable.length > 0,
38
34
  inputSchema: (scope) => ({
39
- ...targetShape(scope, "read", {
40
- collection: "Collection to describe.",
41
- global: "Global to describe."
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 = refOf(resolveTarget(scope, args, "read"));
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 nodes = (expanded?.paths ?? (args.paths && args.paths.length > 0 ? args.paths : [""])).map((schemaPath) => {
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: error instanceof Error ? error.message : "Unknown 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 Promise.resolve(jsonResult(nodes));
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 { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
- import { resolveTarget } from "./target.mjs";
4
- import { defineMcpxTool } from "../types.mjs";
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 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
+ * 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 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.`,
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).describe("Collection to search."),
23
- where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
24
- sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
25
- limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.limits.maxLimit)}.`),
26
- page: z.number().int().min(1).optional().describe("Page number, from 1."),
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: "Locale to read. Defaults to the default locale."
35
+ description: READ_LOCALE_DESCRIPTION
32
36
  }),
33
- draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
37
+ draft: z.boolean().optional().describe("Default true: latest drafts.")
34
38
  }),
35
39
  handler: async ({ args, scope }) => {
36
- resolveTarget(scope, { collection: args.collection }, "read");
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.req,
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, targetShape } from "./shared.mjs";
3
- import { requireIdFor, resolveTarget } from "./target.mjs";
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: `Lists the version history of one document or global, newest first. Returns metadata only; read a version's body with getDocument and "versionId", or what changed with "diffFrom".
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
- ...targetShape(scope, "versions", {
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(`Versions per page. Default 10, at most ${String(scope.limits.maxLimit)}.`),
31
- page: z.number().int().min(1).optional().describe("Page number, from 1."),
32
- status: z.enum(["published", "draft"]).optional().describe("Only versions with this status."),
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 to report. Defaults to the default locale."
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 = resolveTarget(scope, args, "versions");
40
- const id = requireIdFor(target, args.id);
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, findRichTextField, splitPath } from "../schema/walk.mjs";
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 { depthShape, idSchema, idShape, localeOf, localeShape, slugsFor, targetShape } from "./shared.mjs";
7
- import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
8
- import { defineMcpxTool } from "../types.mjs";
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 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.
18
+ const VERSION_PARAGRAPH = `
16
19
 
17
- Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
18
-
19
- Set "outline" on a rich text "path" to get a compact positional listing of its nodes instead of the raw editor state.
20
-
21
- On an entity with versions, "versionId" (from findVersions) reads that version instead. "diffFrom" returns the RFC 6902 operations turning a version, or "published" (the newest version with published status, regardless of locale), into the document read, in the pointer syntax patchDocument takes and limited to "path" when given. To revert, pass the old version as "versionId" and the latest one from findVersions as "diffFrom", then apply the patch with patchDocument.`;
22
- /** Refuses `versionId` and `diffFrom` where they cannot apply. */ const assertVersionArgs = (scope, target, args) => {
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}" keeps no versions.`, 400);
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 a caller reading one branch
42
- * still gets the timestamp `expectedUpdatedAt` wants without a second call.
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.readableGlobals.length > 0,
73
+ isEnabled: (scope) => scope.collections.readable.length + scope.globals.readable.length > 0,
51
74
  inputSchema: (scope) => ({
52
- ...targetShape(scope, "read", {
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("JSON pointer to return only a subtree, e.g. \"/layout/sections/0\"."),
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: "Locale to read. Defaults to the default locale."
81
+ description: READ_LOCALE_DESCRIPTION
62
82
  }),
63
- draft: z.boolean().optional().describe("Return the latest draft. Default true."),
64
- outline: z.boolean().optional().describe("For a rich text field, return a compact positional outline instead of the editor state. Requires \"path\"."),
65
- versionId: idSchema.optional().describe("Version to read instead of the document, from findVersions. Only for entities with versions; not with \"draft\"."),
66
- diffFrom: idSchema.optional().describe("Version id, or \"published\" for the newest published version in any locale, to diff from. Returns {from, to, patch} instead of the document. Only for entities with versions.")
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 = resolveTarget(scope, args, "read");
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 jsonResult(doc);
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.kind === "collection" ? { id } : { global: target.slug },
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: refOf(target)
138
+ ref: target
113
139
  });
114
140
  } catch (error) {
115
- return errorResult(error instanceof Error ? error.message : OUTLINE_ERROR);
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);