@abinnovision/payloadcms-mcpx 2.0.0-beta.9 → 2.1.0-beta.1

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 (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -36
  3. package/dist/admin/capability-matrix.d.mts +5 -3
  4. package/dist/admin/capability-matrix.mjs +374 -272
  5. package/dist/admin/capability-toggles.mjs +79 -42
  6. package/dist/admin/confirmations-panel.mjs +232 -0
  7. package/dist/admin/confirmations.d.mts +15 -0
  8. package/dist/admin/confirmations.mjs +71 -0
  9. package/dist/admin/endpoint-url.mjs +7 -0
  10. package/dist/admin/index.d.mts +2 -1
  11. package/dist/admin/index.mjs +2 -1
  12. package/dist/admin/setup-guide.d.mts +3 -3
  13. package/dist/admin/setup-guide.mjs +51 -33
  14. package/dist/api-keys/capability-matrix.d.mts +7 -9
  15. package/dist/api-keys/capability-matrix.mjs +15 -37
  16. package/dist/api-keys/collection.mjs +16 -18
  17. package/dist/api-keys/confirmation-view.mjs +7 -0
  18. package/dist/api-keys/fields.mjs +50 -57
  19. package/dist/auth/resolve.mjs +69 -40
  20. package/dist/capabilities.d.mts +2 -0
  21. package/dist/capabilities.mjs +64 -16
  22. package/dist/define-tool.d.mts +4 -4
  23. package/dist/define-tool.mjs +8 -1
  24. package/dist/endpoint/confirmations.mjs +124 -0
  25. package/dist/endpoint/handler.mjs +25 -27
  26. package/dist/endpoint/http.mjs +27 -0
  27. package/dist/endpoint/server.mjs +48 -29
  28. package/dist/endpoint/upload.mjs +104 -0
  29. package/dist/guards.mjs +8 -1
  30. package/dist/index.d.mts +2 -2
  31. package/dist/index.mjs +1 -1
  32. package/dist/leases/confirmation.mjs +74 -0
  33. package/dist/leases/grant.mjs +29 -0
  34. package/dist/leases/lease.mjs +115 -0
  35. package/dist/options.mjs +68 -61
  36. package/dist/plugin.d.mts +3 -2
  37. package/dist/plugin.mjs +19 -4
  38. package/dist/request.mjs +5 -1
  39. package/dist/result.mjs +11 -1
  40. package/dist/schema/admin-hidden.mjs +4 -4
  41. package/dist/schema/describe.mjs +12 -18
  42. package/dist/schema/index.mjs +5 -6
  43. package/dist/schema/lexical-pointer.mjs +8 -8
  44. package/dist/schema/lexical.mjs +15 -13
  45. package/dist/schema/outline.mjs +4 -5
  46. package/dist/schema/path.mjs +4 -1
  47. package/dist/schema/pointer.mjs +11 -17
  48. package/dist/schema/shape.mjs +38 -50
  49. package/dist/schema/walk.mjs +57 -18
  50. package/dist/tools/builtin.mjs +7 -1
  51. package/dist/tools/create-document.mjs +38 -26
  52. package/dist/tools/delete-document.mjs +154 -0
  53. package/dist/tools/describe-schema.mjs +7 -5
  54. package/dist/tools/document.mjs +131 -45
  55. package/dist/tools/duplicate-document.mjs +50 -0
  56. package/dist/tools/find-documents.mjs +15 -20
  57. package/dist/tools/find-versions.mjs +11 -15
  58. package/dist/tools/get-document.mjs +31 -15
  59. package/dist/tools/links.mjs +53 -0
  60. package/dist/tools/list-capabilities.mjs +17 -7
  61. package/dist/tools/patch-document.mjs +57 -53
  62. package/dist/tools/publish-document.mjs +17 -29
  63. package/dist/tools/query-paths.mjs +1 -1
  64. package/dist/tools/run-confirmed.mjs +114 -0
  65. package/dist/tools/shared.mjs +53 -46
  66. package/dist/tools/validate-document.mjs +6 -8
  67. package/dist/tools/versions.mjs +11 -6
  68. package/dist/types.d.mts +158 -43
  69. package/dist/upload/file.mjs +178 -0
  70. package/dist/version.mjs +4 -2
  71. package/dist/write/draft-guard.mjs +35 -28
  72. package/dist/write/patch.mjs +4 -4
  73. package/dist/write/publish-intent.mjs +23 -11
  74. package/dist/write/row-ids.mjs +3 -1
  75. package/dist/write/write-data.mjs +18 -31
  76. package/package.json +14 -14
  77. package/dist/builtin-tool-names.mjs +0 -16
@@ -1,16 +1,17 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { errorResult, jsonResult } from "../result.mjs";
3
- import { JSON_POINTER_PATTERN, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
4
- import { findRichTextField } from "../schema/walk.mjs";
5
3
  import { SchemaError } from "../schema/errors.mjs";
4
+ import { JSON_POINTER_PATTERN, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
5
+ import { findFieldAt } from "../schema/walk.mjs";
6
6
  import { lexicalOutline } from "../schema/outline.mjs";
7
7
  import { resolveDataPointer } from "../schema/pointer.mjs";
8
8
  import "../schema/index.mjs";
9
- import { READ_LOCALE_DESCRIPTION, depthShape, entityShape, idSchema, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
9
+ import { downloadHandoff, downloadSlugs } from "../upload/file.mjs";
10
+ import { READ_LOCALE_DESCRIPTION, depthShape, entityShape, idSchema, idShape, localeOf, localeShape, reaches, slugsFor, widen } from "./shared.mjs";
10
11
  import { identityOf, resolveDocument } from "./document.mjs";
11
12
  import { diffDocuments, loadPublished, loadVersion, readLive } from "./versions.mjs";
12
- import { APIError } from "payload";
13
13
  import { z } from "zod";
14
+ import { APIError } from "payload";
14
15
  import { Pointer } from "rfc6902";
15
16
  //#region src/tools/get-document.ts
16
17
  const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
@@ -18,10 +19,6 @@ const VERSION_PARAGRAPH = `
18
19
 
19
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.`;
20
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".`;
21
- const exposesVersions = (scope) => {
22
- const { collections, globals } = slugsFor(scope, "versions");
23
- return collections.length + globals.length > 0;
24
- };
25
22
  const assertVersionArgs = (scope, target, args) => {
26
23
  if (args.versionId === void 0 && args.diffFrom === void 0) return;
27
24
  const { collections, globals } = slugsFor(scope, "versions");
@@ -40,24 +37,36 @@ const diffResult = async (scope, read, diff, doc) => {
40
37
  });
41
38
  };
42
39
  const versionShape = (scope) => {
43
- if (!exposesVersions(scope)) return widen({});
40
+ if (!reaches(scope, "versions")) return widen({});
44
41
  return widen({
45
42
  versionId: idSchema.optional().describe("From findVersions. Not with \"draft\"."),
46
43
  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.")
47
44
  });
48
45
  };
46
+ const downloadShape = (scope) => {
47
+ const slugs = downloadSlugs(scope);
48
+ 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".`) });
49
+ };
50
+ const assertDownloadArgs = (scope, target, args) => {
51
+ if (target.kind === "global" || !downloadSlugs(scope).includes(target.slug)) throw new APIError(`"${target.slug}" has no file to download.`, 400);
52
+ 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);
53
+ };
54
+ const withDownload = async (scope, doc, target) => typeof doc["filename"] === "string" ? jsonResult({
55
+ ...doc,
56
+ download: await downloadHandoff(scope, target)
57
+ }) : errorResult("This document has no file to download.");
49
58
  /**
50
59
  * With `path` the handler returns the subtree plus the `id`, `_status` and
51
60
  * `updatedAt` a client needs to write back, so reading one branch still gives
52
61
  * the `expectedUpdatedAt` timestamp without a second call.
53
62
  */ const getDocument = defineMcpxTool({
54
63
  name: "getDocument",
55
- description: (scope) => exposesVersions(scope) ? DESCRIPTION + VERSION_PARAGRAPH : DESCRIPTION,
64
+ description: (scope) => reaches(scope, "versions") ? DESCRIPTION + VERSION_PARAGRAPH : DESCRIPTION,
56
65
  annotations: {
57
66
  readOnlyHint: true,
58
67
  openWorldHint: false
59
68
  },
60
- isEnabled: (scope) => scope.collections.readable.length + scope.globals.readable.length > 0,
69
+ isEnabled: (scope) => reaches(scope, "read"),
61
70
  inputSchema: (scope) => ({
62
71
  ...entityShape(scope, "read"),
63
72
  ...idShape(scope, "read"),
@@ -69,11 +78,13 @@ const versionShape = (scope) => {
69
78
  }),
70
79
  draft: z.boolean().optional().describe("Default true. false reads the published version."),
71
80
  outline: z.boolean().optional().describe("With \"path\" at a rich text field: list each node's pointer, type, \"version\" and text."),
72
- ...versionShape(scope)
81
+ ...versionShape(scope),
82
+ ...downloadShape(scope)
73
83
  }),
74
84
  handler: async ({ args, scope }) => {
75
85
  const target = resolveDocument(scope, args, "read");
76
86
  assertVersionArgs(scope, target, args);
87
+ if (args.download) assertDownloadArgs(scope, target, args);
77
88
  const prototyped = args.path ? prototypeSegmentProblem(args.path) : void 0;
78
89
  if (prototyped !== void 0) return errorResult(prototyped);
79
90
  let pointer;
@@ -96,12 +107,17 @@ const versionShape = (scope) => {
96
107
  }, doc);
97
108
  if (pointer === void 0) {
98
109
  if (args.outline) return errorResult(OUTLINE_ERROR);
99
- return jsonResult(doc);
110
+ return args.download && args.id !== void 0 ? await withDownload(scope, doc, {
111
+ collection: target.slug,
112
+ id: args.id,
113
+ draft: args.draft ?? true,
114
+ locale: read.locale ?? null
115
+ }) : jsonResult(doc);
100
116
  }
101
117
  const path = pointer.toString();
102
118
  const value = pointer.get(doc);
103
119
  const envelope = {
104
- ...identityOf(target, args.id),
120
+ ...identityOf(target),
105
121
  status: doc["_status"],
106
122
  updatedAt: doc["updatedAt"],
107
123
  path
@@ -121,7 +137,7 @@ const versionShape = (scope) => {
121
137
  if (!(error instanceof SchemaError)) throw error;
122
138
  return errorResult(error.message);
123
139
  }
124
- const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findRichTextField(resolution.fields, splitPath(resolution.descriptor.path)) : void 0;
140
+ const field = resolution.descriptor?.type === "richText" && !resolution.lexical ? findFieldAt(resolution.fields, splitPath(resolution.descriptor.path), "richText") : void 0;
125
141
  if (!field) return errorResult(OUTLINE_ERROR);
126
142
  return jsonResult({
127
143
  ...envelope,
@@ -0,0 +1,53 @@
1
+ import { publicOrigin } from "../request.mjs";
2
+ import { formatAdminURL } from "payload/shared";
3
+ //#region src/tools/links.ts
4
+ const livePreviewOf = (req, target) => {
5
+ if (target.config.admin.livePreview) return target.config.admin.livePreview;
6
+ const root = req.payload.config.admin.livePreview;
7
+ return (target.kind === "collection" ? root?.collections : root?.globals)?.includes(target.slug) ? root : void 0;
8
+ };
9
+ const previewOf = async (req, args) => {
10
+ const { target, doc, locale } = args;
11
+ const { preview } = target.config.admin;
12
+ if (preview) return await preview(doc, {
13
+ locale: locale ?? "",
14
+ req,
15
+ token: null
16
+ });
17
+ const url = livePreviewOf(req, target)?.url;
18
+ if (typeof url !== "function") return url;
19
+ const { localization } = req.payload.config;
20
+ const entry = localization ? localization.locales.find((item) => item.code === locale) : void 0;
21
+ return await url({
22
+ ...target.kind === "collection" ? { collectionConfig: target.config } : { globalConfig: target.config },
23
+ data: doc,
24
+ locale: entry ?? {
25
+ code: locale ?? "",
26
+ label: locale ?? ""
27
+ },
28
+ payload: req.payload,
29
+ req
30
+ });
31
+ };
32
+ /**
33
+ * The admin panel URL of a written document, and its preview URL where the
34
+ * entity configures one. Both sit at the origin of `serverURL` when set, else
35
+ * of the MCP request. A preview that fails is left out: the write has landed.
36
+ */ const documentLinks = async (req, args) => {
37
+ const { target, doc, locale } = args;
38
+ const { config } = req.payload;
39
+ const origin = publicOrigin(req);
40
+ const path = target.kind === "collection" ? `/collections/${target.slug}/${String(doc["id"])}` : `/globals/${target.slug}`;
41
+ const adminUrl = formatAdminURL({
42
+ adminRoute: config.routes.admin,
43
+ path,
44
+ serverURL: origin
45
+ });
46
+ const preview = await previewOf(req, args).catch(() => void 0);
47
+ return {
48
+ adminUrl: locale ? `${adminUrl}?locale=${encodeURIComponent(locale)}` : adminUrl,
49
+ ...preview ? { previewUrl: new URL(preview, origin).toString() } : {}
50
+ };
51
+ };
52
+ //#endregion
53
+ export { documentLinks };
@@ -1,10 +1,13 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
- import { canCreate } from "../capabilities.mjs";
3
2
  import { jsonResult } from "../result.mjs";
4
3
  import { translateStatic, translatorFor } from "../i18n.mjs";
4
+ import { slugsFor } from "./shared.mjs";
5
+ import { globalConfig } from "./document.mjs";
6
+ import { MCPX_ISSUES_URL, MCPX_REPOSITORY_URL } from "../version.mjs";
5
7
  import { hasDraftValidationEnabled } from "payload/shared";
6
8
  //#region src/tools/list-capabilities.ts
7
9
  const DESCRIPTION = `Lists what this key may do. Call it first. Returns each collection and global the key can read or write, with whether it can create, publish, has drafts and exposes versions to findVersions, and a collection's "idType". Also returns the locales, the "limits" on page size and depth, and the enabled custom tools.`;
10
+ const DIAGNOSTICS = ` Its "server" block holds the server version with repository and issue links, for a bug report.`;
8
11
  const translateLabel = (scope, label, fallback) => {
9
12
  const { i18n, t } = scope.req;
10
13
  const resolved = typeof label === "function" ? label({
@@ -21,7 +24,7 @@ const translateLabel = (scope, label, fallback) => {
21
24
  * grows.
22
25
  */ const listCapabilities = defineMcpxTool({
23
26
  name: "listCapabilities",
24
- description: DESCRIPTION,
27
+ description: (scope) => scope.diagnostics ? `${DESCRIPTION}${DIAGNOSTICS}` : DESCRIPTION,
25
28
  annotations: {
26
29
  readOnlyHint: true,
27
30
  openWorldHint: false
@@ -31,10 +34,11 @@ const translateLabel = (scope, label, fallback) => {
31
34
  handler: ({ scope }) => {
32
35
  const { payload } = scope.req;
33
36
  const translate = translatorFor(scope.req.i18n);
37
+ const creatable = slugsFor(scope, "create").collections;
34
38
  const collections = scope.exposure.collections.flatMap((entry) => {
35
39
  const capability = scope.capabilities.collections[entry.slug];
36
40
  const collection = payload.collections[entry.slug];
37
- if (!capability || !collection || !(capability.read || capability.write)) return [];
41
+ if (!capability || !collection || !capability.read) return [];
38
42
  const { config } = collection;
39
43
  const description = translate(config.admin.description);
40
44
  return [{
@@ -46,7 +50,7 @@ const translateLabel = (scope, label, fallback) => {
46
50
  ...description === void 0 ? {} : { description },
47
51
  read: capability.read,
48
52
  write: capability.write,
49
- create: capability.write && canCreate(entry),
53
+ create: creatable.includes(entry.slug),
50
54
  publish: capability.publish,
51
55
  drafts: entry.hasDrafts,
52
56
  versions: entry.hasVersions,
@@ -56,8 +60,8 @@ const translateLabel = (scope, label, fallback) => {
56
60
  });
57
61
  const globals = scope.exposure.globals.flatMap((entry) => {
58
62
  const capability = scope.capabilities.globals[entry.slug];
59
- const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
60
- if (!capability || !config || !(capability.read || capability.write)) return [];
63
+ const config = globalConfig(payload, entry.slug);
64
+ if (!capability || !config || !capability.read) return [];
61
65
  const description = translate(config.admin.description);
62
66
  return [{
63
67
  slug: entry.slug,
@@ -79,7 +83,13 @@ const translateLabel = (scope, label, fallback) => {
79
83
  default: scope.localization.defaultLocale
80
84
  } : null,
81
85
  limits: scope.limits,
82
- tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
86
+ tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name),
87
+ ...scope.diagnostics ? { server: {
88
+ name: scope.diagnostics.name,
89
+ version: scope.diagnostics.version,
90
+ repository: MCPX_REPOSITORY_URL,
91
+ issues: MCPX_ISSUES_URL
92
+ } } : {}
83
93
  });
84
94
  }
85
95
  });
@@ -1,16 +1,16 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { isPlainObject } from "../guards.mjs";
3
- import { errorResult, jsonResult } from "../result.mjs";
3
+ import { errorResult } from "../result.mjs";
4
4
  import { JSON_POINTER_PATTERN } from "../schema/path.mjs";
5
5
  import "../schema/index.mjs";
6
- import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
7
- import { collectPublishBlockers } from "../write/publish-blockers.mjs";
8
- import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
9
- import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
6
+ import { assertAcceptsFile, fileShape, fileSlugs, fileWriteRefusal, uploadHandoff, uploadedFile } from "../upload/file.mjs";
7
+ import { entityShape, expectedUpdatedAtShape, idShape, liveWriteSentence, localeOf, localeShape, reaches } from "./shared.mjs";
8
+ import { readDraft, resolveDocument, staleReadResult, updateTarget, writtenResult } from "./document.mjs";
10
9
  import { withTransaction } from "../write/transaction.mjs";
10
+ import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
11
11
  import { buildWriteData } from "../write/write-data.mjs";
12
- import { Forbidden, NotFound } from "payload";
13
12
  import { z } from "zod";
13
+ import { APIError, Forbidden, NotFound } from "payload";
14
14
  import { Pointer } from "rfc6902";
15
15
  //#region src/tools/patch-document.ts
16
16
  const DESCRIPTION = (scope) => `Applies JSON Patch operations to one document or global, e.g. {"op":"replace","path":"/title","value":"Home"}. If any operation fails, nothing is written and the response lists the problems.
@@ -21,7 +21,7 @@ Read the document with getDocument right before patching. Append to a list with
21
21
 
22
22
  In a rich text field a pointer continues into the rich text state: "/content/root/children/2" is a node, "/content/root/children/2/tag" one of its properties and "/content/root/children/3/fields/tone" a field of a block node. getDocument "outline" lists each node's pointer and "version". Build nodes from describeSchema "nodeProperties". Check a position before writing to it, e.g. {"op":"test","path":"/content/root/children/2/type","value":"heading"}.
23
23
 
24
- The response carries "updatedAt" and, if any, "publishBlockers": what must be fixed before publishing. "publishBlockersUnavailable" means that check failed. The write stands either way. "notApplied" lists pointers whose value did not change or cannot be read back, for example where field access denies the update.`;
24
+ The response carries "updatedAt" and, if any, "publishBlockers": what must be fixed before publishing. "publishBlockersUnavailable" means that check failed. The write stands either way. "notApplied" lists pointers whose value did not change or cannot be read back, for example where field access denies the update. The response links the document with "adminUrl" and "previewUrl".`;
25
25
  const POINTER = z.string().regex(JSON_POINTER_PATTERN);
26
26
  const PATCHES_LIMIT = 500;
27
27
  /**
@@ -75,7 +75,8 @@ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operat
75
75
  * document before writing anything, runs the write in a transaction, then
76
76
  * re-reads the saved document to report which pointers survived and what still
77
77
  * blocks publishing. The draft guard, not this tool, decides where the write
78
- * lands.
78
+ * lands. With `file`, the patches and the file land as one update, and the
79
+ * transaction commits right after it because Payload has moved files by then.
79
80
  */ const patchDocument = defineMcpxTool({
80
81
  name: "patchDocument",
81
82
  description: DESCRIPTION,
@@ -85,7 +86,7 @@ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operat
85
86
  idempotentHint: false,
86
87
  openWorldHint: false
87
88
  },
88
- isEnabled: (scope) => scope.collections.writable.length + scope.globals.writable.length > 0,
89
+ isEnabled: (scope) => reaches(scope, "write"),
89
90
  inputSchema: (scope) => ({
90
91
  ...entityShape(scope, "write"),
91
92
  ...idShape(scope, "write"),
@@ -93,71 +94,74 @@ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operat
93
94
  required: true,
94
95
  description: "Localized fields are written in this locale only."
95
96
  }),
96
- patches: z.array(PATCH_OPERATION_SCHEMA).min(1).max(PATCHES_LIMIT),
97
- expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
97
+ patches: fileSlugs(scope).length === 0 ? z.array(PATCH_OPERATION_SCHEMA).min(1).max(PATCHES_LIMIT) : z.array(PATCH_OPERATION_SCHEMA).max(PATCHES_LIMIT).describe("May be empty when \"file\" is given."),
98
+ ...expectedUpdatedAtShape,
99
+ ...fileShape(scope, (slugs) => `Replaces the file of a document in ${slugs}. The call returns an "upload" for the bytes instead of writing.`)
98
100
  }),
99
101
  handler: async ({ args, scope }) => {
100
102
  const target = resolveDocument(scope, args, "write");
101
103
  const { payload } = scope.req;
102
104
  const locale = localeOf(scope, args.locale);
103
105
  const patches = args.patches;
104
- return await withTransaction(scope.req, async () => {
106
+ const { file } = args;
107
+ const bytes = uploadedFile(scope.req);
108
+ if (file !== void 0) assertAcceptsFile(scope, target, file);
109
+ if (patches.length === 0 && file === void 0) throw new APIError("\"patches\" may be empty only when \"file\" is given.", 400);
110
+ const report = async (next) => {
111
+ const readable = await readDraft(scope, {
112
+ target,
113
+ locale
114
+ }).catch((error) => {
115
+ if (error instanceof NotFound || error instanceof Forbidden) return;
116
+ throw error;
117
+ });
118
+ const notApplied = readable ? notAppliedPointers(patches, next, readable) : [];
119
+ return await writtenResult(scope, {
120
+ target,
121
+ locale,
122
+ extra: notApplied.length > 0 ? { notApplied } : {}
123
+ });
124
+ };
125
+ const outcome = await withTransaction(scope.req, async () => {
105
126
  const doc = await readDraft(scope, {
106
127
  target,
107
128
  locale
108
129
  });
109
130
  const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again and re-apply the patch.");
110
- if (stale) return stale;
131
+ if (stale) return { done: stale };
111
132
  const applied = applyPatchOperations(payload.config, {
112
133
  doc,
113
134
  patches,
114
135
  ref: target
115
136
  });
116
- if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
117
- const write = {
137
+ if ("problems" in applied) return { done: errorResult("No operation was applied.", { problems: applied.problems }) };
138
+ if (file !== void 0 && target.kind === "collection") {
139
+ const refusal = await fileWriteRefusal(scope, {
140
+ entity: target,
141
+ id: target.id,
142
+ data: applied.next
143
+ });
144
+ if (refusal) return { done: refusal };
145
+ if (!bytes) return { handoff: {
146
+ ...args,
147
+ file,
148
+ expectedUpdatedAt: doc["updatedAt"]
149
+ } };
150
+ }
151
+ await updateTarget(scope, target, {
118
152
  data: buildWriteData(payload.config, target.config, applied.next),
119
- depth: 0,
120
153
  draft: true,
121
- overrideAccess: false,
122
- req: scope.req,
123
- ...locale === void 0 ? {} : { locale }
124
- };
125
- if (target.kind === "collection") await payload.update({
126
- ...write,
127
- collection: target.slug,
128
- id: target.id
129
- });
130
- else await payload.updateGlobal({
131
- ...write,
132
- fallbackLocale: false,
133
- slug: target.slug
134
- });
135
- const saved = await readDraft(scope, {
136
- target,
137
154
  locale,
138
- privileged: true
139
- });
140
- const readable = await readDraft(scope, {
141
- target,
142
- locale
143
- }).catch((error) => {
144
- if (error instanceof NotFound || error instanceof Forbidden) return;
145
- throw error;
146
- });
147
- const notApplied = readable ? notAppliedPointers(patches, applied.next, readable) : [];
148
- const validation = await collectPublishBlockers(scope.req, {
149
- doc: saved,
150
- entity: target
151
- });
152
- return jsonResult({
153
- ...identityOf(target, saved["id"]),
154
- status: saved["_status"],
155
- updatedAt: saved["updatedAt"],
156
- ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
157
- ...validation.unavailable ? { publishBlockersUnavailable: true } : {},
158
- ...notApplied.length > 0 ? { notApplied } : {}
155
+ file: bytes
159
156
  });
157
+ return bytes === void 0 ? { done: await report(applied.next) } : { written: applied.next };
158
+ });
159
+ if ("done" in outcome) return outcome.done;
160
+ if ("handoff" in outcome) return await uploadHandoff(scope, {
161
+ tool: "patchDocument",
162
+ args: outcome.handoff
160
163
  });
164
+ return await report(outcome.written);
161
165
  }
162
166
  });
163
167
  //#endregion
@@ -1,30 +1,29 @@
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
- import { withTransaction } from "../write/transaction.mjs";
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
- import { z } from "zod";
7
+ import { withTransaction } from "../write/transaction.mjs";
8
8
  /**
9
9
  * Publishes a draft. Available where the config exposes `publish` on an entity
10
10
  * with drafts and the key has both the `write` and `publish` checkboxes.
11
11
  */ const publishDocument = defineMcpxTool({
12
12
  name: "publishDocument",
13
- description: `Makes the current draft of one document or global public, in every locale or only in "locale". A scoped publish keeps the other locales of localized fields as last published, and a never-published document goes live in full. A draft with publish blockers is refused, so call validateDocument first. Payload checks only the published locale, so "otherLocaleBlockers" lists what other locales still lack. There is no unpublish.`,
13
+ description: `Makes the current draft of one document or global public, in every locale or only in "locale". A scoped publish keeps the other locales of localized fields as last published, and a never-published document goes live in full. A draft with publish blockers is refused, so call validateDocument first. Payload checks only the published locale, so "otherLocaleBlockers" lists what other locales still lack. There is no unpublish. The response links the document with "adminUrl" and "previewUrl".`,
14
14
  annotations: {
15
15
  destructiveHint: true,
16
16
  openWorldHint: false
17
17
  },
18
- isEnabled: (scope) => scope.collections.publishable.length + scope.globals.publishable.length > 0,
18
+ isEnabled: (scope) => reaches(scope, "publish"),
19
19
  inputSchema: (scope) => ({
20
20
  ...entityShape(scope, "publish"),
21
21
  ...idShape(scope, "publish"),
22
22
  ...localeShape(scope, { required: false }),
23
- expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
23
+ ...expectedUpdatedAtShape
24
24
  }),
25
25
  handler: async ({ args, scope }) => {
26
26
  const target = resolveDocument(scope, args, "publish");
27
- const { payload } = scope.req;
28
27
  const locale = localeOf(scope, args.locale);
29
28
  const { localization } = scope;
30
29
  return await withTransaction(scope.req, async () => {
@@ -34,24 +33,11 @@ import { z } from "zod";
34
33
  });
35
34
  const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again before publishing.");
36
35
  if (stale) return stale;
37
- const write = {
36
+ await updateTarget(scope, target, {
38
37
  data: withPublishIntent({}),
39
- depth: 0,
40
38
  draft: false,
41
- fallbackLocale: false,
42
- overrideAccess: false,
43
- req: scope.req,
44
- ...locale === void 0 ? {} : { locale },
45
- ...args.locale === void 0 ? {} : { publishSpecificLocale: args.locale }
46
- };
47
- if (target.kind === "collection") await payload.update({
48
- ...write,
49
- collection: target.slug,
50
- id: target.id
51
- });
52
- else await payload.updateGlobal({
53
- ...write,
54
- slug: target.slug
39
+ locale,
40
+ publishSpecificLocale: args.locale
55
41
  });
56
42
  const saved = await readDraft(scope, {
57
43
  target,
@@ -60,11 +46,13 @@ import { z } from "zod";
60
46
  });
61
47
  const others = await collectLocaleBlockers(scope, target, localization?.locales.filter((entry) => entry !== locale) ?? []);
62
48
  return jsonResult({
63
- ...identityOf(target, saved["id"]),
64
- status: saved["_status"],
65
- updatedAt: saved["updatedAt"],
66
- ...others.blockers.length > 0 ? { otherLocaleBlockers: others.blockers } : {},
67
- ...others.unavailable ? { otherLocaleBlockersUnavailable: true } : {}
49
+ ...documentSummary(target, saved),
50
+ ...await documentLinks(scope.req, {
51
+ target,
52
+ doc: saved,
53
+ locale
54
+ }),
55
+ ...blockerFields(others, "otherLocaleBlockers")
68
56
  });
69
57
  });
70
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);
@@ -0,0 +1,114 @@
1
+ import { defineMcpxTool, isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
+ import { leaseContext } from "../leases/lease.mjs";
3
+ import { claimConfirmation, readConfirmation } from "../leases/confirmation.mjs";
4
+ import { jsonResult, parseResult } from "../result.mjs";
5
+ import { withRequestLocale } from "./document.mjs";
6
+ import { z } from "zod";
7
+ import { APIError } from "payload";
8
+ //#region src/tools/run-confirmed.ts
9
+ const DESCRIPTION = `Runs calls that returned a "confirmation", once the user approved them in the admin panel. Returns one result per id, in the order given: "done" with the call's "result", "pending" while it awaits a decision, "skipped" with a "reason" when it no longer passes its checks, or "refused" when it was rejected, expired, already run or is unknown. A pending id stays usable; no other id can run again.`;
10
+ const NO_LONGER_ALLOWED = "This key no longer allows this call.";
11
+ const CONFIRMED = {
12
+ confirmed: true,
13
+ request: () => Promise.reject(/* @__PURE__ */ new Error("A confirmed run cannot request a confirmation."))
14
+ };
15
+ const execute = async (tool, confirmation, scope, extra) => {
16
+ const { req } = scope;
17
+ const args = toolInputSchema(tool, scope).safeParse(confirmation.args);
18
+ if (!args.success) return {
19
+ status: "skipped",
20
+ reason: NO_LONGER_ALLOWED
21
+ };
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
+ });
56
+ };
57
+ const claimApproved = async (req, slug, apiKeyId, id) => {
58
+ const { payload } = req;
59
+ try {
60
+ const confirmation = await readConfirmation(payload, slug, apiKeyId, id);
61
+ if (confirmation?.state === "pending") return { status: "pending" };
62
+ return confirmation?.state === "approved" && await claimConfirmation(payload, slug, apiKeyId, id, confirmation.exp) ? confirmation : { status: "refused" };
63
+ } catch (error) {
64
+ payload.logger.error({
65
+ err: error,
66
+ msg: "[payloadcms-mcpx] Could not claim a confirmed call."
67
+ });
68
+ return {
69
+ status: "skipped",
70
+ reason: "Internal error"
71
+ };
72
+ }
73
+ };
74
+ /**
75
+ * Runs approved calls of the confirmable tools in `tools`, which is read per
76
+ * call so the list may include this tool. Registered when the key may call one
77
+ * of them. Each call claims its entry first, so it runs at most once.
78
+ */ const createRunConfirmed = (tools) => defineMcpxTool({
79
+ name: "runConfirmed",
80
+ description: DESCRIPTION,
81
+ annotations: {
82
+ destructiveHint: true,
83
+ openWorldHint: false
84
+ },
85
+ isEnabled: (scope) => tools().some((tool) => tool.confirm !== void 0 && isToolEnabled(tool, scope)),
86
+ inputSchema: { ids: z.array(z.string().max(64)).min(1).max(50).describe("\"id\" of each \"confirmation\".") },
87
+ handler: async ({ args, scope, extra }) => {
88
+ const { req } = scope;
89
+ const { slug, apiKeyId } = leaseContext(req);
90
+ const outcomes = [];
91
+ for (const id of args.ids) {
92
+ const confirmation = await claimApproved(req, slug, apiKeyId, id);
93
+ if (!("tool" in confirmation)) {
94
+ outcomes.push({
95
+ id,
96
+ ...confirmation
97
+ });
98
+ continue;
99
+ }
100
+ const tool = tools().find((candidate) => candidate.name === confirmation.tool && candidate.confirm !== void 0);
101
+ const result = tool && isToolEnabled(tool, scope) ? await execute(tool, confirmation, scope, extra) : {
102
+ status: "skipped",
103
+ reason: NO_LONGER_ALLOWED
104
+ };
105
+ outcomes.push({
106
+ id,
107
+ ...result
108
+ });
109
+ }
110
+ return jsonResult({ results: outcomes });
111
+ }
112
+ });
113
+ //#endregion
114
+ export { createRunConfirmed };