@abinnovision/payloadcms-mcpx 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -0,0 +1 @@
1
+ import "payload";
@@ -11,13 +11,22 @@ import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
11
11
  "updatedAt"
12
12
  ]);
13
13
  /**
14
- * Joins path segments, attaching the array marker to its field rather than
15
- * separating it, so an array subfield reads `items[].title`.
16
- */ const joinPath = (parts) => parts.reduce((path, part) => part === "[]" ? `${path}[]` : path ? `${path}.${part}` : part, "");
14
+ * Shape a JSON Pointer must have to be parseable at all.
15
+ */ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
17
16
  /**
18
- * Splits a descriptor path back into pointer-comparable segments, with the
19
- * array marker as a segment of its own.
20
- */ const splitPath = (path) => path.split(".").flatMap((part) => part.endsWith("[]") ? [part.slice(0, -2), "[]"] : [part]);
17
+ * Joins segments into a JSON Pointer, so the segments `items`, `*`, `title`
18
+ * read as one path to a subfield of every element of `items`. No segments is
19
+ * the root pointer, `""`.
20
+ */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
21
+ /**
22
+ * Splits a JSON Pointer into its segments, unescaping `~1` and `~0`. The root
23
+ * pointer yields no segments.
24
+ */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
25
+ /**
26
+ * Restates a path Payload reports on a validation error (`layout.0.title`) as
27
+ * a JSON Pointer, so everything this plugin hands back addresses documents the
28
+ * same way. Payload's path already carries real indices, so it maps directly.
29
+ */ const pointerFromPayloadPath = (path) => path ? joinPath(path.split(".")) : "";
21
30
  /**
22
31
  * Blocks a blocks field accepts, by slug. On a flattened field, whichever of
23
32
  * `blockReferences` and `blocks` was declared carries the definitions.
@@ -83,7 +92,7 @@ const withRows = (descriptor, field) => ({
83
92
  const readOnly = parentReadOnly || isReadOnly(field);
84
93
  const path = [...prefix, field.name];
85
94
  if (field.type === "tab" || field.type === "group") return describeFields(field.flattenedFields, path, readOnly);
86
- if (field.type === "array") return describeFields(field.flattenedFields, [...path, "[]"], readOnly);
95
+ if (field.type === "array") return describeFields(field.flattenedFields, [...path, "*"], readOnly);
87
96
  if (field.type === "blocks") return [withRows({
88
97
  ...describeBase(field, joinPath(path), readOnly),
89
98
  blocks: blockSlugsOf(field)
@@ -97,13 +106,13 @@ const withRows = (descriptor, field) => ({
97
106
  if (!("name" in field) || field.name !== path[0]) continue;
98
107
  if (field.type === "blocks" && path.length === 1) return field;
99
108
  if (field.type === "tab" || field.type === "group") return findBlocksField(field.flattenedFields, path.slice(1));
100
- if (field.type === "array" && path[1] === "[]") return findBlocksField(field.flattenedFields, path.slice(2));
109
+ if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
101
110
  }
102
111
  };
103
- const collectionOf = (config, collection) => {
104
- const found = config.collections.find((candidate) => candidate.slug === collection);
105
- if (!found) throw new Error(`Unknown collection "${collection}".`);
112
+ const targetOf = (config, ref) => {
113
+ const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
114
+ if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
106
115
  return found;
107
116
  };
108
117
  //#endregion
109
- export { RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath, staticDescription };
118
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
@@ -1,5 +1,6 @@
1
1
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed, localeOf, localeShape, readDraft } from "./shared.mjs";
2
+ import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
3
+ import { resolveTarget } from "./target.mjs";
3
4
  import { validateWriteValue } from "../schema/shape.mjs";
4
5
  import { stripRowIds } from "../write/patch.mjs";
5
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
@@ -16,7 +17,7 @@ const createDocument = {
16
17
  },
17
18
  isEnabled: (scope) => scope.writable.length > 0,
18
19
  inputSchema: (scope) => ({
19
- collection: collectionEnum(scope.writable).describe("Collection to create the document in."),
20
+ collection: slugEnum(scope.writable).describe("Collection to create the document in."),
20
21
  ...localeShape(scope, {
21
22
  required: true,
22
23
  description: "Locale the localized fields of the seed belong to."
@@ -24,15 +25,15 @@ const createDocument = {
24
25
  data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
25
26
  }),
26
27
  handler: async (args, scope) => {
27
- const collection = ensureAllowed(scope, args.collection, "write");
28
+ const target = resolveTarget(scope, { collection: args.collection }, "write");
28
29
  const { payload } = scope.req;
29
30
  const locale = localeOf(scope, args.locale);
30
31
  const { id: _ignored, ...seed } = args.data;
31
32
  const problems = validateWriteValue(payload.config, {
32
33
  pointer: "",
33
34
  resolution: {
34
- fields: collection.flattenedFields,
35
- prefix: ""
35
+ fields: target.config.flattenedFields,
36
+ prefix: []
36
37
  }
37
38
  }, seed);
38
39
  if (problems.length > 0) return errorResult("Nothing was created.", { problems });
@@ -45,15 +46,15 @@ const createDocument = {
45
46
  req: scope.req,
46
47
  ...locale === void 0 ? {} : { locale }
47
48
  });
48
- const saved = await readDraft(scope, {
49
- collection: args.collection,
49
+ const saved = await readTarget(scope, {
50
+ target,
50
51
  id: created["id"],
51
52
  locale,
52
53
  privileged: true
53
54
  });
54
55
  const publishBlockers = await collectPublishBlockers(scope.req, {
55
- collection,
56
- doc: saved
56
+ doc: saved,
57
+ entity: target
57
58
  });
58
59
  return jsonResult({
59
60
  id: saved["id"],
@@ -1,5 +1,6 @@
1
1
  import { jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed } from "./shared.mjs";
2
+ import { targetShape } from "./shared.mjs";
3
+ import { refOf, resolveTarget } from "./target.mjs";
3
4
  import { describeNode, reachableSchemaPaths } from "../schema/describe.mjs";
4
5
  import { z } from "zod";
5
6
  //#region src/tools/describe-schema.ts
@@ -7,28 +8,33 @@ const describeSchema = {
7
8
  name: "describeSchema",
8
9
  description: `Describes the writable shape of a document, one node at a time.
9
10
 
10
- 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.
11
+ 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.
11
12
 
12
- Field paths are dotted and already resolved through anything that does not nest in the stored document. To turn one into a patchDocument pointer, replace each "." with "/", add a leading "/", and replace each "[]" with a 0-based index. Note a path here names a block by its slug where a pointer names it by its index.
13
+ 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.
14
+
15
+ 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".
13
16
 
14
17
  Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed and cannot be written. Fields marked readOnly are listed but refused on write.`,
15
18
  annotations: {
16
19
  readOnlyHint: true,
17
20
  openWorldHint: false
18
21
  },
19
- isEnabled: (scope) => scope.readable.length > 0,
22
+ isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
20
23
  inputSchema: (scope) => ({
21
- collection: collectionEnum(scope.readable).describe("Collection to describe."),
22
- paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"layout.sections.sectionWrapper\". Omit for the collection root."),
24
+ ...targetShape(scope, "read", {
25
+ collection: "Collection to describe.",
26
+ global: "Global to describe."
27
+ }),
28
+ paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"/layout/sections/sectionWrapper\". Omit for the collection root."),
23
29
  expand: z.boolean().optional().describe("Return every node reachable from the root in one response. Ignores paths.")
24
30
  }),
25
31
  handler: (args, scope) => {
26
- ensureAllowed(scope, args.collection, "read");
32
+ const ref = refOf(resolveTarget(scope, args, "read"));
27
33
  const { config } = scope.req.payload;
28
- const expanded = args.expand === true ? reachableSchemaPaths(config, args.collection) : void 0;
34
+ const expanded = args.expand === true ? reachableSchemaPaths(config, ref) : void 0;
29
35
  const nodes = (expanded?.paths ?? (args.paths && args.paths.length > 0 ? args.paths : [""])).map((schemaPath) => {
30
36
  try {
31
- return describeNode(config, args.collection, schemaPath);
37
+ return describeNode(config, ref, schemaPath);
32
38
  } catch (error) {
33
39
  return {
34
40
  error: error instanceof Error ? error.message : "Unknown error",
@@ -1,5 +1,6 @@
1
1
  import { jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, depthShape, ensureAllowed, localeOf, localeShape } from "./shared.mjs";
2
+ import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
+ import { resolveTarget } from "./target.mjs";
3
4
  import { z } from "zod";
4
5
  //#region src/tools/find-documents.ts
5
6
  const findDocuments = {
@@ -11,7 +12,7 @@ const findDocuments = {
11
12
  },
12
13
  isEnabled: (scope) => scope.readable.length > 0,
13
14
  inputSchema: (scope) => ({
14
- collection: collectionEnum(scope.readable).describe("Collection to search."),
15
+ collection: slugEnum(scope.readable).describe("Collection to search."),
15
16
  where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
16
17
  sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
17
18
  limit: z.number().int().min(1).max(scope.options.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.options.limits.maxLimit)}.`),
@@ -25,7 +26,7 @@ const findDocuments = {
25
26
  draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
26
27
  }),
27
28
  handler: async (args, scope) => {
28
- ensureAllowed(scope, args.collection, "read");
29
+ resolveTarget(scope, { collection: args.collection }, "read");
29
30
  const locale = localeOf(scope, args.locale);
30
31
  const result = await scope.req.payload.find({
31
32
  collection: args.collection,
@@ -1,20 +1,27 @@
1
+ import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
1
2
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, depthShape, ensureAllowed, idSchema, localeOf, localeShape } from "./shared.mjs";
3
+ import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
4
+ import { requireIdFor, resolveTarget } from "./target.mjs";
3
5
  import { z } from "zod";
4
6
  import { Pointer } from "rfc6902";
5
7
  //#region src/tools/get-document.ts
6
8
  const getDocument = {
7
9
  name: "getDocument",
8
- 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.`,
10
+ 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
+
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.`,
9
13
  annotations: {
10
14
  readOnlyHint: true,
11
15
  openWorldHint: false
12
16
  },
13
- isEnabled: (scope) => scope.readable.length > 0,
17
+ isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
14
18
  inputSchema: (scope) => ({
15
- collection: collectionEnum(scope.readable).describe("Collection holding the document."),
16
- id: idSchema,
17
- path: z.string().optional().describe("JSON pointer to return only a subtree, e.g. \"/layout/sections/0\"."),
19
+ ...targetShape(scope, "read", {
20
+ collection: "Collection holding the document.",
21
+ global: "Global to read."
22
+ }),
23
+ ...idShape(scope, "read"),
24
+ path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("JSON pointer to return only a subtree, e.g. \"/layout/sections/0\"."),
18
25
  ...depthShape(scope),
19
26
  ...localeShape(scope, {
20
27
  required: false,
@@ -23,17 +30,24 @@ const getDocument = {
23
30
  draft: z.boolean().optional().describe("Return the latest draft. Default true.")
24
31
  }),
25
32
  handler: async (args, scope) => {
26
- ensureAllowed(scope, args.collection, "read");
33
+ const target = resolveTarget(scope, args, "read");
34
+ const id = requireIdFor(target, args.id);
27
35
  const locale = localeOf(scope, args.locale);
28
- const doc = await scope.req.payload.findByID({
29
- collection: args.collection,
30
- id: args.id,
36
+ const shared = {
31
37
  depth: args.depth ?? 0,
32
38
  draft: args.draft ?? true,
33
39
  overrideAccess: false,
34
40
  req: scope.req,
35
41
  ...locale === void 0 ? {} : { locale }
36
- });
42
+ };
43
+ const doc = await (target.kind === "collection" ? scope.req.payload.findByID({
44
+ ...shared,
45
+ collection: target.slug,
46
+ id
47
+ }) : scope.req.payload.findGlobal({
48
+ ...shared,
49
+ slug: target.slug
50
+ }));
37
51
  if (args.path === void 0 || args.path === "") return jsonResult(doc);
38
52
  let value;
39
53
  try {
@@ -42,7 +56,7 @@ const getDocument = {
42
56
  return errorResult(`"${args.path}" is not a valid JSON pointer.`);
43
57
  }
44
58
  return jsonResult({
45
- id: doc["id"],
59
+ ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
46
60
  status: doc["_status"],
47
61
  updatedAt: doc["updatedAt"],
48
62
  path: args.path,
@@ -1,11 +1,13 @@
1
+ import { staticDescription } from "../schema/walk.mjs";
1
2
  import { jsonResult } from "../endpoint/result.mjs";
2
3
  import { translateLabel } from "./shared.mjs";
3
- import { staticDescription } from "../schema/walk.mjs";
4
4
  import { hasDraftValidationEnabled } from "payload/shared";
5
5
  //#region src/tools/list-capabilities.ts
6
6
  const listCapabilities = {
7
7
  name: "listCapabilities",
8
- description: `Lists what this key may do: the collections 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.`,
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.
9
+
10
+ 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".`,
9
11
  annotations: {
10
12
  readOnlyHint: true,
11
13
  openWorldHint: false
@@ -34,8 +36,24 @@ const listCapabilities = {
34
36
  idType: collection.customIDType ?? payload.db.defaultIDType
35
37
  }];
36
38
  });
39
+ const globals = scope.options.globals.flatMap((entry) => {
40
+ const capability = scope.capabilities.globals[entry.slug];
41
+ const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
42
+ if (!capability || !config || !(capability.read || capability.write)) return [];
43
+ const description = staticDescription(config.admin.description);
44
+ return [{
45
+ slug: entry.slug,
46
+ label: translateLabel(scope, config.label, entry.slug),
47
+ ...description === void 0 ? {} : { description },
48
+ read: capability.read,
49
+ write: capability.write,
50
+ drafts: entry.hasDrafts,
51
+ draftValidation: hasDraftValidationEnabled(config)
52
+ }];
53
+ });
37
54
  return Promise.resolve(jsonResult({
38
55
  collections,
56
+ ...globals.length > 0 ? { globals } : {},
39
57
  locales: scope.locales ? {
40
58
  codes: scope.locales,
41
59
  default: scope.defaultLocale
@@ -1,5 +1,6 @@
1
1
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed, idSchema, localeOf, localeShape, readDraft } from "./shared.mjs";
2
+ import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
+ import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
3
4
  import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
4
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
5
6
  import { withTransaction } from "../write/transaction.mjs";
@@ -8,9 +9,11 @@ import { Pointer } from "rfc6902";
8
9
  //#region src/tools/patch-document.ts
9
10
  const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
10
11
 
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.
13
+
11
14
  The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
12
15
 
13
- 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. Use describeSchema to find a field's path, then turn it into a pointer by replacing "." with "/" and each "[]" with a 0-based index.
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.
14
17
 
15
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 a concurrent edit is refused rather than overwritten.
16
19
 
@@ -48,10 +51,13 @@ const patchDocument = {
48
51
  idempotentHint: false,
49
52
  openWorldHint: false
50
53
  },
51
- isEnabled: (scope) => scope.writable.length > 0,
54
+ isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
52
55
  inputSchema: (scope) => ({
53
- collection: collectionEnum(scope.writable).describe("Collection holding the document."),
54
- id: idSchema,
56
+ ...targetShape(scope, "write", {
57
+ collection: "Collection holding the document.",
58
+ global: "Global to patch."
59
+ }),
60
+ ...idShape(scope, "write"),
55
61
  ...localeShape(scope, {
56
62
  required: true,
57
63
  description: "Locale the patch applies to. Localized fields write here only."
@@ -60,47 +66,55 @@ const patchDocument = {
60
66
  expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
61
67
  }),
62
68
  handler: async (args, scope) => {
63
- const collection = ensureAllowed(scope, args.collection, "write");
69
+ const target = resolveTarget(scope, args, "write");
70
+ const id = requireIdFor(target, args.id);
64
71
  const { payload } = scope.req;
65
72
  const locale = localeOf(scope, args.locale);
66
73
  return await withTransaction(scope.req, async () => {
67
- const doc = await readDraft(scope, {
68
- collection: args.collection,
69
- id: args.id,
74
+ const doc = await readTarget(scope, {
75
+ target,
76
+ id,
70
77
  locale
71
78
  });
72
79
  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"] });
73
80
  const problems = findPatchProblems(payload.config, {
74
- collection: args.collection,
75
81
  doc,
76
- patches: args.patches
82
+ patches: args.patches,
83
+ ref: refOf(target)
77
84
  });
78
85
  if (problems.length > 0) return errorResult("No operation was applied.", { problems });
79
86
  const applied = applyPatchToCopy(doc, args.patches);
80
87
  if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
81
- await payload.update({
82
- collection: args.collection,
83
- id: args.id,
84
- data: buildWriteData(payload.config, collection, applied.next),
88
+ const write = {
89
+ data: buildWriteData(payload.config, target.config, applied.next),
85
90
  depth: 0,
86
91
  draft: true,
87
92
  overrideAccess: false,
88
93
  req: scope.req,
89
94
  ...locale === void 0 ? {} : { locale }
95
+ };
96
+ if (target.kind === "collection") await payload.update({
97
+ ...write,
98
+ collection: target.slug,
99
+ id
100
+ });
101
+ else await payload.updateGlobal({
102
+ ...write,
103
+ slug: target.slug
90
104
  });
91
- const saved = await readDraft(scope, {
92
- collection: args.collection,
93
- id: args.id,
105
+ const saved = await readTarget(scope, {
106
+ target,
107
+ id,
94
108
  locale,
95
109
  privileged: true
96
110
  });
97
111
  const notApplied = notAppliedPointers(args.patches, applied.next, saved);
98
112
  const publishBlockers = await collectPublishBlockers(scope.req, {
99
- collection,
100
- doc: saved
113
+ doc: saved,
114
+ entity: target
101
115
  });
102
116
  return jsonResult({
103
- id: saved["id"],
117
+ ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
104
118
  status: saved["_status"],
105
119
  updatedAt: saved["updatedAt"],
106
120
  ...publishBlockers.length > 0 ? { publishBlockers } : {},
@@ -1,8 +1,39 @@
1
- import { Forbidden, NotFound } from "payload";
1
+ import { NotFound } from "payload";
2
2
  import { z } from "zod";
3
3
  //#region src/tools/shared.ts
4
- const collectionEnum = (slugs) => z.enum(slugs);
4
+ const slugEnum = (slugs) => z.enum(slugs);
5
5
  const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
6
+ const slugsFor = (scope, operation) => ({
7
+ collections: operation === "read" ? scope.readable : scope.writable,
8
+ globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
9
+ });
10
+ /**
11
+ * The `collection` and `global` arguments.
12
+ *
13
+ * When the key can reach no global, `global` is left out of the shape entirely
14
+ * and `collection` stays required, mirroring how {@link localeShape} omits
15
+ * `locale` when localization is off. A deployment without globals therefore
16
+ * sees exactly the schema it saw before. Only the mixed case makes either
17
+ * argument optional, and the handler enforces the exclusivity there.
18
+ */ const targetShape = (scope, operation, descriptions) => {
19
+ const { collections, globals } = slugsFor(scope, operation);
20
+ if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
21
+ if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
22
+ return {
23
+ collection: slugEnum(collections).optional().describe(descriptions.collection),
24
+ global: slugEnum(globals).optional().describe(descriptions.global)
25
+ };
26
+ };
27
+ /**
28
+ * The `id` argument, which only a collection document has. Omitted when the key
29
+ * can reach no collection, required when it can reach no global, and optional
30
+ * in between, where `requireIdFor` enforces the dependency.
31
+ */ const idShape = (scope, operation) => {
32
+ const { collections, globals } = slugsFor(scope, operation);
33
+ if (collections.length === 0) return {};
34
+ if (globals.length === 0) return { id: idSchema };
35
+ return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
36
+ };
6
37
  /**
7
38
  * The `locale` argument, present only when localization is configured.
8
39
  */ const localeShape = (scope, options) => {
@@ -12,15 +43,6 @@ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
12
43
  };
13
44
  const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.options.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.options.limits.maxDepth)}.`) });
14
45
  /**
15
- * Throws unless the key may perform `operation` on `slug`. The input schema
16
- * already limits the enum, so this only guards against a stale tool list.
17
- */ const ensureAllowed = (scope, slug, operation) => {
18
- const allowed = operation === "read" ? scope.readable : scope.writable;
19
- const collection = scope.req.payload.collections[slug];
20
- if (!allowed.includes(slug) || !collection) throw new Forbidden(scope.req.t);
21
- return collection.config;
22
- };
23
- /**
24
46
  * The locale to operate on: the explicit argument, else the request's, else
25
47
  * the default. `undefined` when localization is off.
26
48
  */ const localeOf = (scope, locale) => {
@@ -31,23 +53,34 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
31
53
  /**
32
54
  * Reads the current draft in a fixed locale with no fallback, which is the
33
55
  * shape that may be written back or validated without mixing locales.
34
- */ const readDraft = async (scope, args) => {
35
- const doc = await scope.req.payload.findByID({
36
- collection: args.collection,
37
- id: args.id,
56
+ */ const readTarget = async (scope, args) => {
57
+ const { payload } = scope.req;
58
+ const privileged = args.privileged === true;
59
+ const shared = {
38
60
  depth: 0,
39
61
  draft: true,
40
62
  ...args.locale === void 0 ? {} : {
41
63
  locale: args.locale,
42
64
  fallbackLocale: false
43
65
  },
44
- overrideAccess: args.privileged === true,
45
- showHiddenFields: args.privileged === true,
46
- disableErrors: true,
66
+ overrideAccess: privileged,
67
+ showHiddenFields: privileged,
47
68
  req: scope.req
69
+ };
70
+ if (args.target.kind === "collection") {
71
+ const doc = await payload.findByID({
72
+ ...shared,
73
+ collection: args.target.slug,
74
+ id: args.id,
75
+ disableErrors: true
76
+ });
77
+ if (!doc) throw new NotFound(scope.req.t);
78
+ return doc;
79
+ }
80
+ return await payload.findGlobal({
81
+ ...shared,
82
+ slug: args.target.slug
48
83
  });
49
- if (!doc) throw new NotFound(scope.req.t);
50
- return doc;
51
84
  };
52
85
  /**
53
86
  * Resolves a collection label for the request's language.
@@ -62,4 +95,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
62
95
  return fallback;
63
96
  };
64
97
  //#endregion
65
- export { collectionEnum, depthShape, ensureAllowed, idSchema, localeOf, localeShape, readDraft, translateLabel };
98
+ export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
@@ -0,0 +1,3 @@
1
+ import "./types.mjs";
2
+ import "../schema/walk.mjs";
3
+ import "payload";
@@ -0,0 +1,49 @@
1
+ import { APIError, Forbidden } from "payload";
2
+ //#region src/tools/target.ts
3
+ const refOf = (target) => ({
4
+ kind: target.kind,
5
+ slug: target.slug
6
+ });
7
+ /**
8
+ * Resolves the `collection`/`global` arguments to one entity and checks the key
9
+ * may perform `operation` on it.
10
+ *
11
+ * A tool's `inputSchema` returns a raw shape, which leaves no top-level
12
+ * `.refine` to express "exactly one of collection and global". The rule is
13
+ * enforced here instead, with a message naming the offending arguments so one
14
+ * failed call teaches it.
15
+ */ const resolveTarget = (scope, args, operation) => {
16
+ const { collection, global } = args;
17
+ if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
18
+ 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);
19
+ if (collection !== void 0) {
20
+ const allowed = operation === "read" ? scope.readable : scope.writable;
21
+ const found = scope.req.payload.collections[collection];
22
+ if (!allowed.includes(collection) || !found) throw new Forbidden(scope.req.t);
23
+ return {
24
+ kind: "collection",
25
+ slug: collection,
26
+ config: found.config
27
+ };
28
+ }
29
+ const slug = global;
30
+ const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
31
+ const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
32
+ if (!allowed.includes(slug) || !found) throw new Forbidden(scope.req.t);
33
+ return {
34
+ kind: "global",
35
+ slug,
36
+ config: found
37
+ };
38
+ };
39
+ /**
40
+ * Checks `id` against the resolved target. A collection document needs one; a
41
+ * global is a singleton and must not carry one. The schema cannot express the
42
+ * dependency, so it is stated here and in every affected tool description.
43
+ */ const requireIdFor = (target, id) => {
44
+ if (target.kind === "collection" && id === void 0) throw new APIError(`"id" is required when "collection" is "${target.slug}".`, 400);
45
+ if (target.kind === "global" && id !== void 0) throw new APIError(`"id" must be omitted when "global" is "${target.slug}"; a global is a singleton.`, 400);
46
+ return target.kind === "collection" ? id : void 0;
47
+ };
48
+ //#endregion
49
+ export { refOf, requireIdFor, resolveTarget };
@@ -0,0 +1,5 @@
1
+ import "../types.mjs";
2
+ import "../options.mjs";
3
+ import { PayloadRequest } from "payload";
4
+ import { z } from "zod";
5
+ import { CallToolResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";