@abinnovision/payloadcms-mcpx 2.0.0-beta.19 → 2.0.0-beta.20

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 (63) hide show
  1. package/dist/admin/capability-matrix.mjs +213 -316
  2. package/dist/admin/capability-toggles.mjs +24 -31
  3. package/dist/admin/confirmations-panel.mjs +61 -71
  4. package/dist/admin/confirmations.mjs +2 -1
  5. package/dist/admin/endpoint-url.mjs +7 -0
  6. package/dist/admin/setup-guide.mjs +2 -1
  7. package/dist/api-keys/capability-matrix.d.mts +3 -6
  8. package/dist/api-keys/capability-matrix.mjs +9 -48
  9. package/dist/api-keys/collection.mjs +2 -5
  10. package/dist/api-keys/confirmation-view.mjs +2 -1
  11. package/dist/api-keys/fields.mjs +33 -55
  12. package/dist/auth/resolve.mjs +29 -10
  13. package/dist/capabilities.d.mts +2 -0
  14. package/dist/capabilities.mjs +62 -19
  15. package/dist/endpoint/confirmations.mjs +14 -14
  16. package/dist/endpoint/handler.mjs +6 -22
  17. package/dist/endpoint/http.mjs +27 -0
  18. package/dist/endpoint/upload.mjs +11 -43
  19. package/dist/grants/confirmation.mjs +11 -42
  20. package/dist/grants/grant.mjs +58 -40
  21. package/dist/guards.mjs +8 -1
  22. package/dist/index.d.mts +2 -2
  23. package/dist/index.mjs +1 -1
  24. package/dist/options.mjs +34 -54
  25. package/dist/plugin.mjs +2 -1
  26. package/dist/request.mjs +5 -1
  27. package/dist/result.mjs +11 -1
  28. package/dist/schema/admin-hidden.mjs +4 -4
  29. package/dist/schema/describe.mjs +12 -18
  30. package/dist/schema/index.mjs +5 -6
  31. package/dist/schema/lexical-pointer.mjs +8 -8
  32. package/dist/schema/lexical.mjs +15 -13
  33. package/dist/schema/outline.mjs +4 -5
  34. package/dist/schema/path.mjs +4 -1
  35. package/dist/schema/pointer.mjs +11 -17
  36. package/dist/schema/shape.mjs +38 -50
  37. package/dist/schema/walk.mjs +57 -18
  38. package/dist/tools/create-document.mjs +16 -44
  39. package/dist/tools/delete-document.mjs +14 -17
  40. package/dist/tools/describe-schema.mjs +7 -5
  41. package/dist/tools/document.mjs +130 -44
  42. package/dist/tools/duplicate-document.mjs +11 -10
  43. package/dist/tools/find-documents.mjs +15 -20
  44. package/dist/tools/find-versions.mjs +10 -14
  45. package/dist/tools/get-document.mjs +8 -12
  46. package/dist/tools/links.mjs +2 -1
  47. package/dist/tools/list-capabilities.mjs +2 -1
  48. package/dist/tools/patch-document.mjs +12 -44
  49. package/dist/tools/publish-document.mjs +9 -27
  50. package/dist/tools/query-paths.mjs +1 -1
  51. package/dist/tools/run-confirmed.mjs +38 -50
  52. package/dist/tools/shared.mjs +43 -41
  53. package/dist/tools/validate-document.mjs +4 -6
  54. package/dist/tools/versions.mjs +11 -6
  55. package/dist/types.d.mts +12 -26
  56. package/dist/upload/file.mjs +10 -18
  57. package/dist/write/draft-guard.mjs +20 -20
  58. package/dist/write/patch.mjs +4 -4
  59. package/dist/write/publish-intent.mjs +22 -28
  60. package/dist/write/row-ids.mjs +3 -1
  61. package/dist/write/write-data.mjs +18 -31
  62. package/package.json +5 -5
  63. package/dist/builtin-tool-names.mjs +0 -19
@@ -1,11 +1,10 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { jsonResult } from "../result.mjs";
3
- import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
4
- import { collectLocaleBlockers, identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
5
3
  import { documentLinks } from "./links.mjs";
4
+ import { entityShape, expectedUpdatedAtShape, idShape, localeOf, localeShape, reaches } from "./shared.mjs";
5
+ import { blockerFields, collectLocaleBlockers, documentSummary, readDraft, resolveDocument, staleReadResult, updateTarget } from "./document.mjs";
6
6
  import { withPublishIntent } from "../write/publish-intent.mjs";
7
7
  import { withTransaction } from "../write/transaction.mjs";
8
- import { z } from "zod";
9
8
  /**
10
9
  * Publishes a draft. Available where the config exposes `publish` on an entity
11
10
  * with drafts and the key has both the `write` and `publish` checkboxes.
@@ -16,16 +15,15 @@ import { z } from "zod";
16
15
  destructiveHint: true,
17
16
  openWorldHint: false
18
17
  },
19
- isEnabled: (scope) => scope.collections.publishable.length + scope.globals.publishable.length > 0,
18
+ isEnabled: (scope) => reaches(scope, "publish"),
20
19
  inputSchema: (scope) => ({
21
20
  ...entityShape(scope, "publish"),
22
21
  ...idShape(scope, "publish"),
23
22
  ...localeShape(scope, { required: false }),
24
- expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
23
+ ...expectedUpdatedAtShape
25
24
  }),
26
25
  handler: async ({ args, scope }) => {
27
26
  const target = resolveDocument(scope, args, "publish");
28
- const { payload } = scope.req;
29
27
  const locale = localeOf(scope, args.locale);
30
28
  const { localization } = scope;
31
29
  return await withTransaction(scope.req, async () => {
@@ -35,24 +33,11 @@ import { z } from "zod";
35
33
  });
36
34
  const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again before publishing.");
37
35
  if (stale) return stale;
38
- const write = {
36
+ await updateTarget(scope, target, {
39
37
  data: withPublishIntent({}),
40
- depth: 0,
41
38
  draft: false,
42
- fallbackLocale: false,
43
- overrideAccess: false,
44
- req: scope.req,
45
- ...locale === void 0 ? {} : { locale },
46
- ...args.locale === void 0 ? {} : { publishSpecificLocale: args.locale }
47
- };
48
- if (target.kind === "collection") await payload.update({
49
- ...write,
50
- collection: target.slug,
51
- id: target.id
52
- });
53
- else await payload.updateGlobal({
54
- ...write,
55
- slug: target.slug
39
+ locale,
40
+ publishSpecificLocale: args.locale
56
41
  });
57
42
  const saved = await readDraft(scope, {
58
43
  target,
@@ -61,16 +46,13 @@ import { z } from "zod";
61
46
  });
62
47
  const others = await collectLocaleBlockers(scope, target, localization?.locales.filter((entry) => entry !== locale) ?? []);
63
48
  return jsonResult({
64
- ...identityOf(target, saved["id"]),
65
- status: saved["_status"],
66
- updatedAt: saved["updatedAt"],
49
+ ...documentSummary(target, saved),
67
50
  ...await documentLinks(scope.req, {
68
51
  target,
69
52
  doc: saved,
70
53
  locale
71
54
  }),
72
- ...others.blockers.length > 0 ? { otherLocaleBlockers: others.blockers } : {},
73
- ...others.unavailable ? { otherLocaleBlockersUnavailable: true } : {}
55
+ ...blockerFields(others, "otherLocaleBlockers")
74
56
  });
75
57
  });
76
58
  }
@@ -48,7 +48,7 @@ const isRefusedPath = (scope, args, seen = /* @__PURE__ */ new Set()) => {
48
48
  const reaches = (path) => isRefusedPath(scope, {
49
49
  collection: args.collection,
50
50
  path: normalise(path),
51
- locale: args.locale ?? void 0
51
+ locale: args.locale
52
52
  });
53
53
  for (const path of wherePaths(args.where)) if (reaches(path)) throw new APIError(`This key cannot query through "${path}".`, 400);
54
54
  for (const path of sortPaths(args.sort)) if (reaches(path) || normalise(path).split(".").some(reaches)) throw new APIError(`This key cannot query through "${path}".`, 400);
@@ -1,7 +1,8 @@
1
1
  import { defineMcpxTool, isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
- import { grantKvSlug } from "../grants/grant.mjs";
2
+ import { grantContext } from "../grants/grant.mjs";
3
3
  import { claimConfirmation, readConfirmation } from "../grants/confirmation.mjs";
4
- import { jsonResult } from "../result.mjs";
4
+ import { jsonResult, parseResult } from "../result.mjs";
5
+ import { withRequestLocale } from "./document.mjs";
5
6
  import { z } from "zod";
6
7
  import { APIError } from "payload";
7
8
  //#region src/tools/run-confirmed.ts
@@ -11,14 +12,6 @@ const CONFIRMED = {
11
12
  confirmed: true,
12
13
  request: () => Promise.reject(/* @__PURE__ */ new Error("A confirmed run cannot request a confirmation."))
13
14
  };
14
- const parseResult = (result) => {
15
- const [first] = result.content;
16
- try {
17
- return first?.type === "text" ? JSON.parse(first.text) : {};
18
- } catch {
19
- return { text: first?.type === "text" ? first.text : void 0 };
20
- }
21
- };
22
15
  const execute = async (tool, confirmation, scope, extra) => {
23
16
  const { req } = scope;
24
17
  const args = toolInputSchema(tool, scope).safeParse(confirmation.args);
@@ -26,42 +19,40 @@ const execute = async (tool, confirmation, scope, extra) => {
26
19
  status: "skipped",
27
20
  reason: NO_LONGER_ALLOWED
28
21
  };
29
- const { fallbackLocale, locale } = req;
30
- req.locale = confirmation.locale;
31
- req.fallbackLocale = confirmation.fallbackLocale;
32
- try {
33
- const result = await tool.handler({
34
- args: args.data,
35
- scope,
36
- req,
37
- extra,
38
- confirmation: CONFIRMED
39
- });
40
- const data = parseResult(result);
41
- return result.isError === true ? {
42
- status: "skipped",
43
- reason: String(data["error"])
44
- } : {
45
- status: "done",
46
- result: data
47
- };
48
- } catch (error) {
49
- if (error instanceof APIError && error.isPublic) return {
50
- status: "skipped",
51
- reason: error.message
52
- };
53
- req.payload.logger.error({
54
- err: error,
55
- msg: "[payloadcms-mcpx] Confirmed call failed."
56
- });
57
- return {
58
- status: "skipped",
59
- reason: "Internal error"
60
- };
61
- } finally {
62
- if (locale !== void 0) req.locale = locale;
63
- if (fallbackLocale !== void 0) req.fallbackLocale = fallbackLocale;
64
- }
22
+ return await withRequestLocale(req, async () => {
23
+ req.locale = confirmation.locale;
24
+ req.fallbackLocale = confirmation.fallbackLocale;
25
+ try {
26
+ const result = await tool.handler({
27
+ args: args.data,
28
+ scope,
29
+ req,
30
+ extra,
31
+ confirmation: CONFIRMED
32
+ });
33
+ const data = parseResult(result);
34
+ return result.isError === true ? {
35
+ status: "skipped",
36
+ reason: String(data["error"])
37
+ } : {
38
+ status: "done",
39
+ result: data
40
+ };
41
+ } catch (error) {
42
+ if (error instanceof APIError && error.isPublic) return {
43
+ status: "skipped",
44
+ reason: error.message
45
+ };
46
+ req.payload.logger.error({
47
+ err: error,
48
+ msg: "[payloadcms-mcpx] Confirmed call failed."
49
+ });
50
+ return {
51
+ status: "skipped",
52
+ reason: "Internal error"
53
+ };
54
+ }
55
+ });
65
56
  };
66
57
  const claimApproved = async (req, slug, apiKeyId, id) => {
67
58
  const { payload } = req;
@@ -95,10 +86,7 @@ const claimApproved = async (req, slug, apiKeyId, id) => {
95
86
  inputSchema: { ids: z.array(z.string().max(64)).min(1).max(50).describe("\"id\" of each \"confirmation\".") },
96
87
  handler: async ({ args, scope, extra }) => {
97
88
  const { req } = scope;
98
- const { payload } = req;
99
- const slug = grantKvSlug(payload.config);
100
- const apiKeyId = req.context.mcpx?.apiKeyId;
101
- if (slug === void 0 || apiKeyId === void 0) throw new Error("Confirmations need a database KV and a key.");
89
+ const { slug, apiKeyId } = grantContext(req);
102
90
  const outcomes = [];
103
91
  for (const id of args.ids) {
104
92
  const confirmation = await claimApproved(req, slug, apiKeyId, id);
@@ -1,11 +1,10 @@
1
- import { canCreate, isLiveWrite } from "../capabilities.mjs";
2
1
  import { acceptsFiles } from "../upload/file.mjs";
3
2
  import { z } from "zod";
4
3
  //#region src/tools/shared.ts
5
4
  /**
6
- * An out-of-scope slug fails schema validation before a handler runs, so a
5
+ * A value outside the list fails schema validation before a handler runs, so a
7
6
  * client only ever sees what its key may touch.
8
- */ const slugEnum = (slugs) => z.enum(slugs);
7
+ */ const stringEnum = (values) => z.enum(values);
9
8
  /**
10
9
  * Payload's id type follows the adapter, so both forms are handed on as read.
11
10
  */ const idSchema = z.union([z.string(), z.number()]);
@@ -15,7 +14,10 @@ const slugsOf = (scope, key) => ({
15
14
  });
16
15
  const slugsWhere = (scope, predicate, allowed) => {
17
16
  const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
18
- return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
17
+ return {
18
+ collections: pick(scope.exposure.collections, allowed.collections),
19
+ globals: pick(scope.exposure.globals, allowed.globals)
20
+ };
19
21
  };
20
22
  /**
21
23
  * Collections this key may write but not create in: upload collections whose
@@ -29,52 +31,33 @@ const widen = (branch) => branch;
29
31
  * The one list the shape helpers and {@link resolveEntity} both read.
30
32
  */ const slugsFor = (scope, operation) => {
31
33
  switch (operation) {
32
- case "create": return {
33
- collections: slugsWhere(scope, (entity) => canCreate(entity) && (!entity.isUpload || acceptsFiles(scope, entity.slug)), {
34
- collections: scope.collections.writable,
35
- globals: []
36
- }),
34
+ case "create": return slugsWhere(scope, (entity) => entity.write && (!entity.isUpload || acceptsFiles(scope, entity.slug)), {
35
+ collections: scope.collections.writable,
37
36
  globals: []
38
- };
37
+ });
39
38
  case "delete": return {
40
39
  collections: scope.collections.deletable,
41
40
  globals: []
42
41
  };
43
42
  case "publish": return slugsOf(scope, "publishable");
44
43
  case "read": return slugsOf(scope, "readable");
45
- case "versions": return {
46
- collections: slugsWhere(scope, (entity) => entity.hasVersions, {
47
- collections: scope.collections.readable,
48
- globals: []
49
- }),
50
- globals: slugsWhere(scope, (entity) => entity.hasVersions, {
51
- collections: [],
52
- globals: scope.globals.readable
53
- })
54
- };
44
+ case "versions": return slugsWhere(scope, (entity) => entity.hasVersions, slugsOf(scope, "readable"));
55
45
  case "write": return slugsOf(scope, "writable");
56
46
  }
57
47
  };
58
48
  /**
59
- * The slugs of {@link slugsFor} `"versions"` that also have drafts. Only they
60
- * have `_status` on their versions, so only they can be queried by status.
61
- */ const draftVersionSlugs = (scope) => {
62
- const { collections, globals } = slugsFor(scope, "versions");
63
- return {
64
- collections: slugsWhere(scope, (entity) => entity.hasDrafts, {
65
- collections,
66
- globals: []
67
- }),
68
- globals: slugsWhere(scope, (entity) => entity.hasDrafts, {
69
- collections: [],
70
- globals
71
- })
72
- };
49
+ * Whether `operation` reaches any collection or global.
50
+ */ const reaches = (scope, operation) => {
51
+ const { collections, globals } = slugsFor(scope, operation);
52
+ return collections.length + globals.length > 0;
73
53
  };
74
54
  /**
75
55
  * Slugs reachable by `operation` whose writes go live, because they have no
76
56
  * draft to write.
77
- */ const liveWriteSlugs = (scope, operation) => slugsWhere(scope, isLiveWrite, slugsFor(scope, operation));
57
+ */ const liveWriteSlugs = (scope, operation) => {
58
+ const { collections, globals } = slugsWhere(scope, (entity) => entity.liveWrite, slugsFor(scope, operation));
59
+ return [...collections, ...globals];
60
+ };
78
61
  /**
79
62
  * Stated on the tools that write, since a client calling them must know before
80
63
  * the call which writes are public.
@@ -89,11 +72,11 @@ const widen = (branch) => branch;
89
72
  * `globalRule` states that rule on `global` for a tool without an `id`.
90
73
  */ const entityShape = (scope, operation, globalRule) => {
91
74
  const { collections, globals } = slugsFor(scope, operation);
92
- if (globals.length === 0) return widen({ collection: slugEnum(collections) });
93
- if (collections.length === 0) return widen({ global: slugEnum(globals) });
94
- const global = slugEnum(globals).optional();
75
+ if (globals.length === 0) return widen({ collection: stringEnum(collections) });
76
+ if (collections.length === 0) return widen({ global: stringEnum(globals) });
77
+ const global = stringEnum(globals).optional();
95
78
  return widen({
96
- collection: slugEnum(collections).optional(),
79
+ collection: stringEnum(collections).optional(),
97
80
  global: globalRule === void 0 ? global : global.describe(globalRule)
98
81
  });
99
82
  };
@@ -111,7 +94,7 @@ const widen = (branch) => branch;
111
94
  */ const READ_LOCALE_DESCRIPTION = "Default: the default locale. A value missing in this locale is shown from the default locale.";
112
95
  const localeShape = (scope, options) => {
113
96
  if (!scope.localization) return widen({});
114
- const enumerated = z.enum(scope.localization.locales);
97
+ const enumerated = stringEnum(scope.localization.locales);
115
98
  const locale = options.required ? enumerated : enumerated.optional();
116
99
  return widen({ locale: options.description === void 0 ? locale : locale.describe(options.description) });
117
100
  };
@@ -120,6 +103,25 @@ const localeShape = (scope, options) => {
120
103
  * it can write back, and populating a relation costs a query.
121
104
  */ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe("Default 0. A relationship into a collection this key cannot read stays an id.") });
122
105
  /**
106
+ * For the writes that refuse a document changed since the client read it.
107
+ */ const expectedUpdatedAtShape = { expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.") };
108
+ /**
109
+ * `limit` is bounded by the configured ceiling, so the client sees it instead
110
+ * of being clamped silently.
111
+ */ const pageShape = (scope) => ({
112
+ limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe("Default 10."),
113
+ page: z.number().int().min(1).optional()
114
+ });
115
+ /**
116
+ * The paging fields of a result.
117
+ */ const pageFields = (result) => ({
118
+ totalDocs: result.totalDocs,
119
+ page: result.page,
120
+ totalPages: result.totalPages,
121
+ limit: result.limit,
122
+ hasNextPage: result.hasNextPage
123
+ });
124
+ /**
123
125
  * The locale to operate on: the explicit argument, else the request's, else
124
126
  * the default. `undefined` when localization is off.
125
127
  */ const localeOf = (scope, locale) => {
@@ -129,4 +131,4 @@ const localeShape = (scope, options) => {
129
131
  return requested && locales.includes(requested) ? requested : defaultLocale;
130
132
  };
131
133
  //#endregion
132
- export { READ_LOCALE_DESCRIPTION, depthShape, draftVersionSlugs, entityShape, idSchema, idShape, liveWriteSentence, liveWriteSlugs, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor, widen };
134
+ export { READ_LOCALE_DESCRIPTION, depthShape, entityShape, expectedUpdatedAtShape, idSchema, idShape, liveWriteSentence, liveWriteSlugs, localeOf, localeShape, pageFields, pageShape, patchOnlySlugs, reaches, slugsFor, slugsWhere, stringEnum, widen };
@@ -1,8 +1,8 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { jsonResult } from "../result.mjs";
3
3
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
4
- import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
5
- import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from "./document.mjs";
4
+ import { entityShape, idShape, localeOf, localeShape, reaches } from "./shared.mjs";
5
+ import { collectLocaleBlockers, documentSummary, readDraft, resolveDocument } from "./document.mjs";
6
6
  /**
7
7
  * Gated on write, which implies read, because publish blockers only mean
8
8
  * something to a caller who can act on them.
@@ -15,7 +15,7 @@ import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from ".
15
15
  name: "validateDocument",
16
16
  description: `Lists what still blocks publishing one document or global, without saving. Checks one locale when "locale" is given, otherwise every locale. Returns "publishBlockers", each with a pointer and a message, and a "locale" when every locale was checked. An empty list means it can be published, unless "publishBlockersUnavailable" is true: then the check itself failed. The check runs the field hooks a save runs.`,
17
17
  annotations: { openWorldHint: false },
18
- isEnabled: (scope) => scope.collections.writable.length + scope.globals.writable.length > 0,
18
+ isEnabled: (scope) => reaches(scope, "write"),
19
19
  inputSchema: (scope) => ({
20
20
  ...entityShape(scope, "write"),
21
21
  ...idShape(scope, "write"),
@@ -39,9 +39,7 @@ import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from ".
39
39
  entity: target
40
40
  });
41
41
  return jsonResult({
42
- ...identityOf(target, doc["id"]),
43
- status: doc["_status"],
44
- updatedAt: doc["updatedAt"],
42
+ ...documentSummary(target, doc),
45
43
  publishBlockers: validation.blockers,
46
44
  ...validation.unavailable ? { publishBlockersUnavailable: true } : {}
47
45
  });
@@ -1,3 +1,4 @@
1
+ import { definedProps, isPlainObject } from "../guards.mjs";
1
2
  import { stripAdminHidden } from "../schema/admin-hidden.mjs";
2
3
  import "../schema/index.mjs";
3
4
  import { mcpxReadRequest } from "./read-request.mjs";
@@ -21,8 +22,10 @@ const NEWEST_FIRST = ["-updatedAt", "-id"];
21
22
  draft: options.draft,
22
23
  overrideAccess: false,
23
24
  req: mcpxReadRequest(scope),
24
- ...options.select === void 0 ? {} : { select: options.select },
25
- ...read.locale === void 0 ? {} : { locale: read.locale }
25
+ ...definedProps({
26
+ select: options.select,
27
+ locale: read.locale
28
+ })
26
29
  };
27
30
  const doc = await (read.target.kind === "collection" ? scope.req.payload.findByID({
28
31
  ...shared,
@@ -51,7 +54,7 @@ const assertReadable = async (scope, read) => {
51
54
  * `parent` comes back as a raw id or, populated, as the document itself.
52
55
  */ const isVersionOf = (version, id) => {
53
56
  const parent = version["parent"];
54
- const parentId = typeof parent === "object" && parent !== null ? parent["id"] : parent;
57
+ const parentId = isPlainObject(parent) ? parent["id"] : parent;
55
58
  return (typeof parentId === "string" || typeof parentId === "number") && String(parentId) === String(id);
56
59
  };
57
60
  /**
@@ -68,7 +71,7 @@ const assertReadable = async (scope, read) => {
68
71
  disableErrors: true,
69
72
  overrideAccess: false,
70
73
  req: mcpxReadRequest(scope),
71
- ...read.locale === void 0 ? {} : { locale: read.locale }
74
+ ...definedProps({ locale: read.locale })
72
75
  };
73
76
  const version = await (read.target.kind === "collection" ? payload.findVersionByID({
74
77
  ...shared,
@@ -96,8 +99,10 @@ const assertReadable = async (scope, read) => {
96
99
  overrideAccess: false,
97
100
  req: mcpxReadRequest(scope),
98
101
  where,
99
- ...options.page === void 0 ? {} : { page: options.page },
100
- ...read.locale === void 0 ? {} : { locale: read.locale }
102
+ ...definedProps({
103
+ page: options.page,
104
+ locale: read.locale
105
+ })
101
106
  };
102
107
  return await (read.target.kind === "collection" ? scope.req.payload.findVersions({
103
108
  ...shared,
package/dist/types.d.mts CHANGED
@@ -12,17 +12,6 @@ declare module "payload" {
12
12
  "@abinnovision/payloadcms-mcpx": McpxPluginOptions;
13
13
  }
14
14
  }
15
- /**
16
- * How far an exposed entity lets MCP writes reach.
17
- *
18
- * - `false`: no write tool touches it.
19
- * - `"draft"`: writes land as drafts and nothing MCP does changes what the
20
- * public sees. Requires `versions.drafts`.
21
- * - `"live"`: MCP may change live content. On an entity with drafts that
22
- * exposes `publishDocument`. On one without, there is no draft, so the write
23
- * itself is permitted and lands live.
24
- */
25
- export type McpxWriteMode = "draft" | "live" | false;
26
15
  /**
27
16
  * What an entity exposes. The config names the entities that are reachable and
28
17
  * only takes capabilities away; a key's checkboxes decide per key. `true` is
@@ -62,12 +51,12 @@ export interface McpxCollectionOptions {
62
51
  export type McpxGlobalOptions = Omit<McpxCollectionOptions, "delete">;
63
52
  export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
64
53
  /**
65
- * What the config exposes, before an API key's checkboxes narrow it.
54
+ * What the config exposes, before an API key's checkboxes narrow it. The
55
+ * operations are the config's side of {@link McpxEntityCapabilities}: `write`
56
+ * covers draft and live writes, and `publish` is only true where drafts exist.
66
57
  */
67
- export interface McpxExposedEntity {
58
+ export interface McpxExposedEntity extends McpxEntityCapabilities {
68
59
  slug: string;
69
- read: boolean;
70
- write: McpxWriteMode;
71
60
  hasDrafts: boolean;
72
61
  /**
73
62
  * The entity has Payload `versions`, with or without drafts, and is
@@ -75,13 +64,9 @@ export interface McpxExposedEntity {
75
64
  */
76
65
  hasVersions: boolean;
77
66
  /**
78
- * The config exposes `deleteDocument`. Always `false` on a global.
67
+ * Writes go live immediately, as the entity keeps no drafts.
79
68
  */
80
- delete: boolean;
81
- /**
82
- * The config lets a key move documents to trash without approval.
83
- */
84
- deleteUnattended: boolean;
69
+ liveWrite: boolean;
85
70
  /**
86
71
  * An upload document is a file, which only an upload grant can supply.
87
72
  */
@@ -92,7 +77,7 @@ export interface McpxExposedEntity {
92
77
  fieldName: string;
93
78
  }
94
79
  /**
95
- * The slugs a key may read, write and publish.
80
+ * The slugs a key may read, write, publish and delete.
96
81
  */
97
82
  export interface McpxScopeSlugs {
98
83
  readable: string[];
@@ -346,9 +331,10 @@ export type McpxPluginOptions = {
346
331
  diagnostics?: boolean;
347
332
  };
348
333
  /**
349
- * What a key may do with one entity. Globals reuse this shape.
334
+ * One entity's operations, as the config exposes them or as a key may use
335
+ * them. Globals reuse this shape.
350
336
  */
351
- export interface McpxCollectionCapabilities {
337
+ export interface McpxEntityCapabilities {
352
338
  read: boolean;
353
339
  write: boolean;
354
340
  /**
@@ -368,8 +354,8 @@ export interface McpxCollectionCapabilities {
368
354
  * In force for one request: the plugin config and the key checkboxes together.
369
355
  */
370
356
  export interface McpxResolvedCapabilities {
371
- collections: Record<string, McpxCollectionCapabilities>;
372
- globals: Record<string, McpxCollectionCapabilities>;
357
+ collections: Record<string, McpxEntityCapabilities>;
358
+ globals: Record<string, McpxEntityCapabilities>;
373
359
  tools: Record<string, boolean>;
374
360
  }
375
361
  /**
@@ -1,4 +1,5 @@
1
- import { grantKvSlug, issueGrant } from "../grants/grant.mjs";
1
+ import { grantContext, issueGrant } from "../grants/grant.mjs";
2
+ import { publicOrigin } from "../request.mjs";
2
3
  import { errorResult, jsonResult } from "../result.mjs";
3
4
  import { SchemaError } from "../schema/errors.mjs";
4
5
  import { resolveDataPointer } from "../schema/pointer.mjs";
@@ -24,7 +25,7 @@ const fileSchema = (maxBytes) => z.strictObject({
24
25
  const allowedMimeTypes = (scope, slug) => {
25
26
  if (!scope.uploads || !scope.collections.writable.includes(slug)) return [];
26
27
  const entity = scope.exposure.collections.find((candidate) => candidate.slug === slug);
27
- const config = scope.req.payload.config.collections.find((candidate) => candidate.slug === slug);
28
+ const config = scope.req.payload.collections[slug]?.config;
28
29
  return entity?.isUpload === true && Array.isArray(config?.upload.mimeTypes) ? config.upload.mimeTypes : [];
29
30
  };
30
31
  /**
@@ -115,20 +116,10 @@ const isClientWritable = (scope, target, path) => {
115
116
  return blockers.length === 0 ? void 0 : errorResult("Nothing was written: the document fails validation.", { validationErrors: blockers });
116
117
  };
117
118
  const handoffUrl = (req, path) => {
118
- const request = new URL(req.url ?? "");
119
- const url = new URL(req.payload.config.serverURL || request.origin);
120
- url.pathname = `${request.pathname.replace(/\/+$/, "")}/${path}`;
119
+ const url = new URL(publicOrigin(req));
120
+ url.pathname = `${new URL(req.url ?? "").pathname.replace(/\/+$/, "")}/${path}`;
121
121
  return url.toString();
122
122
  };
123
- const grantContext = (req) => {
124
- const slug = grantKvSlug(req.payload.config);
125
- const apiKeyId = req.context.mcpx?.apiKeyId;
126
- if (slug === void 0 || apiKeyId === void 0) throw new Error("A handoff was offered without a database KV or key.");
127
- return {
128
- slug,
129
- apiKeyId
130
- };
131
- };
132
123
  /**
133
124
  * Stores the call as a grant and returns where to PUT its file. Nothing else
134
125
  * is written: the PUT runs the same call again with the bytes attached.
@@ -137,11 +128,12 @@ const grantContext = (req) => {
137
128
  const { payload } = req;
138
129
  const { slug, apiKeyId } = grantContext(req);
139
130
  const url = handoffUrl(req, "upload");
140
- const { grantId, exp } = await issueGrant(payload, slug, {
131
+ const { id, exp } = await issueGrant(payload, slug, {
141
132
  kind: "upload",
142
133
  apiKeyId,
143
134
  tool: call.tool,
144
135
  args: call.args,
136
+ file: call.args.file,
145
137
  locale: req.locale ?? null,
146
138
  fallbackLocale: req.fallbackLocale ?? null
147
139
  });
@@ -150,7 +142,7 @@ const grantContext = (req) => {
150
142
  method: "PUT",
151
143
  headers: {
152
144
  "content-type": call.args.file.mimeType,
153
- "x-mcpx-grant": grantId
145
+ "x-mcpx-grant": id
154
146
  },
155
147
  expiresAt: new Date(exp).toISOString(),
156
148
  maxBytes: uploadMaxBytes(payload.config)
@@ -168,7 +160,7 @@ const grantContext = (req) => {
168
160
  const { req } = scope;
169
161
  const { slug, apiKeyId } = grantContext(req);
170
162
  const url = handoffUrl(req, "file");
171
- const { grantId, exp } = await issueGrant(req.payload, slug, {
163
+ const { id, exp } = await issueGrant(req.payload, slug, {
172
164
  kind: "download",
173
165
  apiKeyId,
174
166
  ...target,
@@ -177,7 +169,7 @@ const grantContext = (req) => {
177
169
  return {
178
170
  url,
179
171
  method: "GET",
180
- headers: { "x-mcpx-grant": grantId },
172
+ headers: { "x-mcpx-grant": id },
181
173
  expiresAt: new Date(exp).toISOString()
182
174
  };
183
175
  };
@@ -1,5 +1,6 @@
1
- import { hasPublishIntent, hasTrashIntent, takeDuplicateIntent, takePublishIntent, takeTrashIntent, withTrashIntent } from "./publish-intent.mjs";
1
+ import { propOf } from "../guards.mjs";
2
2
  import { isMcpxRequest } from "../request.mjs";
3
+ import { hasPublishIntent, hasTrashIntent, takeDuplicateIntent, takePublishIntent, takeTrashIntent, withTrashIntent } from "./publish-intent.mjs";
3
4
  import { APIError } from "payload";
4
5
  import { hasDraftsEnabled } from "payload/shared";
5
6
  //#region src/write/draft-guard.ts
@@ -58,7 +59,7 @@ const refuseUnlessExpected = (req, slug, data) => {
58
59
  const publishing = takePublishIntent(data);
59
60
  const trashing = takeTrashIntent(data);
60
61
  if (!isMcpxRequest(req) || trashing) return;
61
- const status = data._status;
62
+ const status = propOf(data, "_status");
62
63
  const expected = publishing ? "published" : "draft";
63
64
  if (status === expected) return;
64
65
  req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
@@ -76,30 +77,29 @@ const refusePublishGlobal = ({ data, global, req }) => {
76
77
  refuseUnlessExpected(req, global.slug, next);
77
78
  return next;
78
79
  };
79
- /**
80
- * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
81
- * (it does nothing outside MCP requests) and `refusePublish` wherever drafts
82
- * exist. It runs on the final collection list, so no collection escapes it, and
83
- * both hooks are appended last, so a user hook cannot override them.
84
- */ const installDraftGuards = (collections) => collections.map((collection) => ({
85
- ...collection,
80
+ const appendGuards = (entities, guards) => entities.map((entity) => ({
81
+ ...entity,
86
82
  hooks: {
87
- ...collection.hooks,
88
- beforeOperation: [...collection.hooks?.beforeOperation ?? [], forceDraftWrite],
89
- ...hasDraftsEnabled(collection) ? { beforeChange: [...collection.hooks?.beforeChange ?? [], refusePublish] } : {}
83
+ ...entity.hooks,
84
+ beforeOperation: [...entity.hooks?.beforeOperation ?? [], guards.beforeOperation],
85
+ ...hasDraftsEnabled(entity) ? { beforeChange: [...entity.hooks?.beforeChange ?? [], guards.beforeChange] } : {}
90
86
  }
91
87
  }));
92
88
  /**
89
+ * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
90
+ * (it does nothing outside MCP requests) and `refusePublish` wherever drafts
91
+ * exist. It runs on the final collection list, so no collection escapes it.
92
+ */ const installDraftGuards = (collections) => appendGuards(collections, {
93
+ beforeChange: refusePublish,
94
+ beforeOperation: forceDraftWrite
95
+ });
96
+ /**
93
97
  * Attaches the guard to every global, exposed or not: a custom tool running on
94
98
  * an MCP request must not be able to publish through a global the plugin config
95
99
  * never mentioned.
96
- */ const installGlobalDraftGuards = (globals) => globals.map((global) => ({
97
- ...global,
98
- hooks: {
99
- ...global.hooks,
100
- beforeOperation: [...global.hooks?.beforeOperation ?? [], forceDraftWriteGlobal],
101
- ...hasDraftsEnabled(global) ? { beforeChange: [...global.hooks?.beforeChange ?? [], refusePublishGlobal] } : {}
102
- }
103
- }));
100
+ */ const installGlobalDraftGuards = (globals) => appendGuards(globals, {
101
+ beforeChange: refusePublishGlobal,
102
+ beforeOperation: forceDraftWriteGlobal
103
+ });
104
104
  //#endregion
105
105
  export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };