@abinnovision/payloadcms-mcpx 2.0.0-beta.12 → 2.0.0-beta.14

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 (39) hide show
  1. package/README.md +32 -20
  2. package/dist/admin/capability-toggles.mjs +28 -8
  3. package/dist/admin/confirmations-panel.mjs +242 -0
  4. package/dist/admin/confirmations.d.mts +15 -0
  5. package/dist/admin/confirmations.mjs +70 -0
  6. package/dist/admin/index.d.mts +2 -1
  7. package/dist/admin/index.mjs +2 -1
  8. package/dist/api-keys/capability-matrix.d.mts +2 -0
  9. package/dist/api-keys/capability-matrix.mjs +12 -0
  10. package/dist/api-keys/collection.mjs +3 -3
  11. package/dist/api-keys/confirmation-view.mjs +6 -0
  12. package/dist/api-keys/fields.mjs +18 -1
  13. package/dist/builtin-tool-names.mjs +3 -1
  14. package/dist/capabilities.mjs +6 -2
  15. package/dist/define-tool.d.mts +4 -4
  16. package/dist/define-tool.mjs +8 -1
  17. package/dist/endpoint/confirmations.mjs +124 -0
  18. package/dist/endpoint/handler.mjs +2 -2
  19. package/dist/endpoint/server.mjs +22 -18
  20. package/dist/endpoint/upload.mjs +5 -4
  21. package/dist/grants/confirmation.mjs +106 -0
  22. package/dist/{upload → grants}/grant.mjs +39 -16
  23. package/dist/options.mjs +28 -5
  24. package/dist/plugin.d.mts +2 -1
  25. package/dist/plugin.mjs +5 -2
  26. package/dist/tools/builtin.mjs +5 -1
  27. package/dist/tools/create-document.mjs +1 -1
  28. package/dist/tools/delete-document.mjs +157 -0
  29. package/dist/tools/find-versions.mjs +1 -1
  30. package/dist/tools/get-document.mjs +1 -1
  31. package/dist/tools/patch-document.mjs +2 -2
  32. package/dist/tools/publish-document.mjs +1 -1
  33. package/dist/tools/run-confirmed.mjs +126 -0
  34. package/dist/tools/shared.mjs +4 -0
  35. package/dist/types.d.mts +129 -18
  36. package/dist/upload/file.mjs +25 -6
  37. package/dist/write/draft-guard.mjs +11 -9
  38. package/dist/write/publish-intent.mjs +16 -6
  39. package/package.json +1 -1
@@ -0,0 +1,157 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
2
+ import { errorResult, jsonResult } from "../result.mjs";
3
+ import { translateStatic } from "../i18n.mjs";
4
+ import { entityShape, idShape, slugsFor } from "./shared.mjs";
5
+ import { resolveDocument, staleReadResult } from "./document.mjs";
6
+ import { withTrashIntent } from "../write/publish-intent.mjs";
7
+ import { withTransaction } from "../write/transaction.mjs";
8
+ import { z } from "zod";
9
+ import { Forbidden, NotFound, combineQueries, executeAccess } from "payload";
10
+ import { formatAdminURL } from "payload/shared";
11
+ //#region src/tools/delete-document.ts
12
+ const DESCRIPTION = `Asks to delete one document. This call deletes nothing: it returns a "confirmation" with an "id" and a "url". Give the url to the user, who approves or rejects in the admin panel, then pass the id to runConfirmed, which deletes. Refused when the document is unknown, already in trash, changed since "expectedUpdatedAt" or not deletable by the user.`;
13
+ const permanenceSentence = (scope) => {
14
+ const permanent = slugsFor(scope, "delete").collections.filter((slug) => scope.req.payload.collections[slug]?.config.trash !== true);
15
+ if (permanent.length === 0) return " Documents move to trash.";
16
+ const sentence = ` Deletes in ${permanent.join(", ")} are permanent.`;
17
+ return permanent.length === slugsFor(scope, "delete").collections.length ? sentence : `${sentence} Other documents move to trash.`;
18
+ };
19
+ const unattendedSentence = (scope) => {
20
+ const unattended = slugsFor(scope, "delete").collections.filter((slug) => scope.capabilities.collections[slug]?.deleteUnattended === true);
21
+ return unattended.length === 0 ? "" : ` Exception: in ${unattended.join(", ")} this call moves the document to trash at once and returns the delete result, with no confirmation.`;
22
+ };
23
+ const mayDelete = async (req, target) => {
24
+ const { config } = target;
25
+ const data = config.trash ? { deletedAt: (/* @__PURE__ */ new Date()).toISOString() } : {};
26
+ const rules = config.trash ? [config.access.delete, config.access.update] : [config.access.delete];
27
+ let where = { id: { equals: target.id } };
28
+ for (const rule of rules) {
29
+ const result = await executeAccess({
30
+ id: target.id,
31
+ data,
32
+ disableErrors: true,
33
+ req
34
+ }, rule);
35
+ if (result === false) return false;
36
+ if (typeof result === "object") where = combineQueries(where, result);
37
+ }
38
+ const { totalDocs } = await req.payload.count({
39
+ collection: target.slug,
40
+ where,
41
+ overrideAccess: true,
42
+ trash: true,
43
+ req
44
+ });
45
+ return totalDocs > 0;
46
+ };
47
+ /**
48
+ * Deletes one collection document once the key's user approved the call. The
49
+ * first call only checks and stores it; `runConfirmed` runs it again with the
50
+ * stored arguments, pinned to the `updatedAt` read here. Available where the
51
+ * config exposes `delete` and the key has the `delete` checkbox.
52
+ */ const deleteDocument = defineMcpxTool({
53
+ name: "deleteDocument",
54
+ description: (scope) => `${DESCRIPTION}${permanenceSentence(scope)}${unattendedSentence(scope)}`,
55
+ annotations: {
56
+ destructiveHint: true,
57
+ openWorldHint: false
58
+ },
59
+ isEnabled: (scope) => scope.uploads && scope.collections.deletable.length > 0,
60
+ inputSchema: (scope) => ({
61
+ ...entityShape(scope, "delete"),
62
+ ...idShape(scope, "delete"),
63
+ expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since."),
64
+ reason: z.string().max(500).optional().describe("Why, shown to the user who approves.")
65
+ }),
66
+ confirm: {
67
+ label: "Delete documents",
68
+ summarize: async ({ args }, req) => {
69
+ const slug = String(args["collection"]);
70
+ const id = args["id"];
71
+ const config = req.payload.collections[slug]?.config;
72
+ const titleField = config?.admin.useAsTitle;
73
+ const read = async (draft) => config ? await req.payload.findByID({
74
+ collection: slug,
75
+ id,
76
+ depth: 0,
77
+ draft,
78
+ overrideAccess: false,
79
+ disableErrors: true,
80
+ req
81
+ }) : null;
82
+ const titleOf = (doc) => {
83
+ const value = titleField === void 0 ? void 0 : doc?.[titleField];
84
+ return typeof value === "string" && value !== "" ? value : void 0;
85
+ };
86
+ const published = await read(false);
87
+ const live = titleOf(published);
88
+ const latest = titleOf(await read(true));
89
+ const title = live === void 0 ? latest : latest !== void 0 && latest !== live ? `${live} (draft: ${latest})` : live;
90
+ const status = published?.["_status"];
91
+ return {
92
+ label: (typeof config?.labels.singular === "function" ? void 0 : translateStatic(config?.labels.singular, req.i18n)) ?? slug,
93
+ ...title === void 0 ? {} : { title },
94
+ ...typeof status === "string" ? { status } : {},
95
+ id: String(id),
96
+ href: formatAdminURL({
97
+ adminRoute: req.payload.config.routes.admin,
98
+ path: `/collections/${slug}/${String(id)}`
99
+ }),
100
+ permanent: config?.trash !== true,
101
+ ...typeof args["reason"] === "string" ? { reason: args["reason"] } : {}
102
+ };
103
+ }
104
+ },
105
+ handler: async ({ args, scope, confirmation }) => {
106
+ const target = resolveDocument(scope, args, "delete");
107
+ const { req } = scope;
108
+ const { payload } = req;
109
+ const trash = target.config.trash ?? false;
110
+ return await withTransaction(req, async () => {
111
+ const doc = await payload.findByID({
112
+ collection: target.slug,
113
+ id: target.id,
114
+ depth: 0,
115
+ draft: true,
116
+ trash: true,
117
+ overrideAccess: false,
118
+ disableErrors: true,
119
+ req
120
+ });
121
+ if (!doc) throw new NotFound(req.t);
122
+ if (doc["deletedAt"]) return errorResult("The document is already in the trash. Emptying the trash is not available.");
123
+ const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again before deleting.");
124
+ if (stale) return stale;
125
+ if (!await mayDelete(req, target)) throw new Forbidden(req.t);
126
+ const unattended = trash && scope.capabilities.collections[target.slug]?.deleteUnattended === true;
127
+ if (!confirmation.confirmed && !unattended) return await confirmation.request({
128
+ ...args,
129
+ expectedUpdatedAt: new Date(doc["updatedAt"]).toISOString()
130
+ });
131
+ if (trash) await payload.update({
132
+ collection: target.slug,
133
+ id: target.id,
134
+ data: withTrashIntent({ deletedAt: (/* @__PURE__ */ new Date()).toISOString() }),
135
+ depth: 0,
136
+ overrideAccess: false,
137
+ overrideLock: false,
138
+ req
139
+ });
140
+ else await payload.delete({
141
+ collection: target.slug,
142
+ id: target.id,
143
+ depth: 0,
144
+ overrideAccess: false,
145
+ overrideLock: false,
146
+ req
147
+ });
148
+ return jsonResult({
149
+ collection: target.slug,
150
+ id: target.id,
151
+ movedToTrash: trash
152
+ });
153
+ });
154
+ }
155
+ });
156
+ //#endregion
157
+ export { deleteDocument };
@@ -3,9 +3,9 @@ import { jsonResult } from "../result.mjs";
3
3
  import { draftVersionSlugs, entityShape, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
4
4
  import { resolveDocument } from "./document.mjs";
5
5
  import { queryVersions } from "./versions.mjs";
6
+ import { z } from "zod";
6
7
  import { APIError } from "payload";
7
8
  import { hasDraftsEnabled } from "payload/shared";
8
- import { z } from "zod";
9
9
  //#region src/tools/find-versions.ts
10
10
  const DESCRIPTION = `Lists the versions of one document or global, newest first, autosaves included. Returns per version "versionId", "createdAt", "updatedAt", "status", "latest", "autosave" and, where set, "publishedLocale", without the content. Read a version with getDocument "versionId", or compare it with "diffFrom".`;
11
11
  const statusShape = (scope) => {
@@ -10,8 +10,8 @@ import { downloadHandoff, downloadSlugs } from "../upload/file.mjs";
10
10
  import { READ_LOCALE_DESCRIPTION, depthShape, entityShape, idSchema, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
11
11
  import { identityOf, resolveDocument } from "./document.mjs";
12
12
  import { diffDocuments, loadPublished, loadVersion, readLive } from "./versions.mjs";
13
- import { APIError } from "payload";
14
13
  import { z } from "zod";
14
+ import { APIError } from "payload";
15
15
  import { Pointer } from "rfc6902";
16
16
  //#region src/tools/get-document.ts
17
17
  const OUTLINE_ERROR = "\"outline\" applies to a rich text field; give \"path\" for one.";
@@ -7,11 +7,11 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
7
7
  import { assertAcceptsFile, fileShape, fileSlugs, fileWriteRefusal, uploadHandoff, uploadedFile } from "../upload/file.mjs";
8
8
  import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
9
9
  import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
10
- import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
11
10
  import { withTransaction } from "../write/transaction.mjs";
11
+ import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
12
12
  import { buildWriteData } from "../write/write-data.mjs";
13
- import { APIError, Forbidden, NotFound } from "payload";
14
13
  import { z } from "zod";
14
+ import { APIError, Forbidden, NotFound } from "payload";
15
15
  import { Pointer } from "rfc6902";
16
16
  //#region src/tools/patch-document.ts
17
17
  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.
@@ -2,8 +2,8 @@ import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { jsonResult } from "../result.mjs";
3
3
  import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
4
4
  import { collectLocaleBlockers, identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
5
- import { withTransaction } from "../write/transaction.mjs";
6
5
  import { withPublishIntent } from "../write/publish-intent.mjs";
6
+ import { withTransaction } from "../write/transaction.mjs";
7
7
  import { z } from "zod";
8
8
  /**
9
9
  * Publishes a draft. Available where the config exposes `publish` on an entity
@@ -0,0 +1,126 @@
1
+ import { defineMcpxTool, isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
+ import { grantKvSlug } from "../grants/grant.mjs";
3
+ import { claimConfirmation, readConfirmation } from "../grants/confirmation.mjs";
4
+ import { jsonResult } from "../result.mjs";
5
+ import { z } from "zod";
6
+ import { APIError } from "payload";
7
+ //#region src/tools/run-confirmed.ts
8
+ 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.`;
9
+ const NO_LONGER_ALLOWED = "This key no longer allows this call.";
10
+ const CONFIRMED = {
11
+ confirmed: true,
12
+ request: () => Promise.reject(/* @__PURE__ */ new Error("A confirmed run cannot request a confirmation."))
13
+ };
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
+ const execute = async (tool, confirmation, scope, extra) => {
23
+ const { req } = scope;
24
+ const args = toolInputSchema(tool, scope).safeParse(confirmation.args);
25
+ if (!args.success) return {
26
+ status: "skipped",
27
+ reason: NO_LONGER_ALLOWED
28
+ };
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
+ }
65
+ };
66
+ const claimApproved = async (req, slug, apiKeyId, id) => {
67
+ const { payload } = req;
68
+ try {
69
+ const confirmation = await readConfirmation(payload, slug, apiKeyId, id);
70
+ if (confirmation?.state === "pending") return { status: "pending" };
71
+ return confirmation?.state === "approved" && await claimConfirmation(payload, slug, apiKeyId, id, confirmation.exp) ? confirmation : { status: "refused" };
72
+ } catch (error) {
73
+ payload.logger.error({
74
+ err: error,
75
+ msg: "[payloadcms-mcpx] Could not claim a confirmed call."
76
+ });
77
+ return {
78
+ status: "skipped",
79
+ reason: "Internal error"
80
+ };
81
+ }
82
+ };
83
+ /**
84
+ * Runs approved calls of the confirmable tools in `tools`, which is read per
85
+ * call so the list may include this tool. Registered when the key may call one
86
+ * of them. Each call claims its entry first, so it runs at most once.
87
+ */ const createRunConfirmed = (tools) => defineMcpxTool({
88
+ name: "runConfirmed",
89
+ description: DESCRIPTION,
90
+ annotations: {
91
+ destructiveHint: true,
92
+ openWorldHint: false
93
+ },
94
+ isEnabled: (scope) => tools().some((tool) => tool.confirm !== void 0 && isToolEnabled(tool, scope)),
95
+ inputSchema: { ids: z.array(z.string().max(64)).min(1).max(50).describe("\"id\" of each \"confirmation\".") },
96
+ handler: async ({ args, scope, extra }) => {
97
+ 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.");
102
+ const outcomes = [];
103
+ for (const id of args.ids) {
104
+ const confirmation = await claimApproved(req, slug, apiKeyId, id);
105
+ if (!("tool" in confirmation)) {
106
+ outcomes.push({
107
+ id,
108
+ ...confirmation
109
+ });
110
+ continue;
111
+ }
112
+ const tool = tools().find((candidate) => candidate.name === confirmation.tool && candidate.confirm !== void 0);
113
+ const result = tool && isToolEnabled(tool, scope) ? await execute(tool, confirmation, scope, extra) : {
114
+ status: "skipped",
115
+ reason: NO_LONGER_ALLOWED
116
+ };
117
+ outcomes.push({
118
+ id,
119
+ ...result
120
+ });
121
+ }
122
+ return jsonResult({ results: outcomes });
123
+ }
124
+ });
125
+ //#endregion
126
+ export { createRunConfirmed };
@@ -36,6 +36,10 @@ const widen = (branch) => branch;
36
36
  }),
37
37
  globals: []
38
38
  };
39
+ case "delete": return {
40
+ collections: scope.collections.deletable,
41
+ globals: []
42
+ };
39
43
  case "publish": return slugsOf(scope, "publishable");
40
44
  case "read": return slugsOf(scope, "readable");
41
45
  case "versions": return {
package/dist/types.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DocumentId } from "./entity.mjs";
2
- import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
3
2
  import { z } from "zod";
3
+ import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
4
4
  import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
5
5
  import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
6
6
  //#region src/types.d.ts
@@ -45,12 +45,20 @@ export interface McpxCollectionOptions {
45
45
  * `false` keeps writes as drafts and is refused where there are none.
46
46
  */
47
47
  publish?: boolean;
48
+ /**
49
+ * Expose `deleteDocument`, which deletes one document once the key's user
50
+ * approved the call in the admin panel. On a collection with `trash` it
51
+ * moves the document to trash. `"unattended"` also lets a key with its own
52
+ * checkbox move documents to trash without approval; it needs `trash`.
53
+ * Default `false`.
54
+ */
55
+ delete?: boolean | "unattended";
48
56
  }
49
57
  /**
50
- * The same options. A singleton, so neither `findDocuments` nor
51
- * `createDocument` reaches one.
58
+ * The same options without `delete`. A singleton, so neither `findDocuments`,
59
+ * `createDocument` nor `deleteDocument` reaches one.
52
60
  */
53
- export type McpxGlobalOptions = McpxCollectionOptions;
61
+ export type McpxGlobalOptions = Omit<McpxCollectionOptions, "delete">;
54
62
  export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
55
63
  /**
56
64
  * What the config exposes, before an API key's checkboxes narrow it.
@@ -65,6 +73,14 @@ export interface McpxExposedEntity {
65
73
  * readable. Says nothing about drafts; see `hasDrafts`.
66
74
  */
67
75
  hasVersions: boolean;
76
+ /**
77
+ * The config exposes `deleteDocument`. Always `false` on a global.
78
+ */
79
+ delete: boolean;
80
+ /**
81
+ * The config lets a key move documents to trash without approval.
82
+ */
83
+ deleteUnattended: boolean;
68
84
  /**
69
85
  * An upload document is a file, which only an upload grant can supply.
70
86
  */
@@ -81,6 +97,7 @@ export interface McpxScopeSlugs {
81
97
  readable: string[];
82
98
  writable: string[];
83
99
  publishable: string[];
100
+ deletable: string[];
84
101
  }
85
102
  /**
86
103
  * What a tool knows about the current request.
@@ -103,7 +120,7 @@ export interface McpxToolScope {
103
120
  };
104
121
  /**
105
122
  * Whether files can be uploaded at all: the KV is database-backed and no
106
- * custom `auth.resolve` is configured.
123
+ * custom `auth.resolve` is configured. Confirmations need the same.
107
124
  */
108
125
  uploads: boolean;
109
126
  /**
@@ -119,11 +136,9 @@ export interface McpxToolScope {
119
136
  };
120
137
  }
121
138
  /**
122
- * A tool, builtin or custom. Runs with `req.user` resolved from the key and
123
- * `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
124
- * per request, leaving no static shape to infer from.
139
+ * What every tool declares, apart from `confirm` and the handler.
125
140
  */
126
- export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
141
+ interface McpxToolBase<Shape extends z.ZodRawShape> {
127
142
  /**
128
143
  * camelCase, unique, not one of the builtin tool names.
129
144
  */
@@ -145,15 +160,103 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
145
160
  * instead of stripped.
146
161
  */
147
162
  inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
148
- handler(ctx: {
149
- args: Args;
150
- scope: McpxToolScope;
151
- /**
152
- * Shorthand for `scope.req`.
153
- */
154
- req: PayloadRequest;
155
- extra: McpxToolExtra;
156
- }): CallToolResult | Promise<CallToolResult>;
163
+ }
164
+ /**
165
+ * What a handler receives.
166
+ */
167
+ export interface McpxToolContext<Args> {
168
+ args: Args;
169
+ scope: McpxToolScope;
170
+ /**
171
+ * Shorthand for `scope.req`.
172
+ */
173
+ req: PayloadRequest;
174
+ extra: McpxToolExtra;
175
+ }
176
+ /**
177
+ * Handed to the handler of a tool with `confirm`. Internal for now.
178
+ */
179
+ export interface McpxConfirmation {
180
+ /**
181
+ * `true` when `runConfirmed` runs an approved call.
182
+ */
183
+ confirmed: boolean;
184
+ /**
185
+ * Stores the call with `args` for the key's user to approve and returns
186
+ * the result that hands its id and URL to the client. Throws in a
187
+ * confirmed run.
188
+ */
189
+ request: (args: Record<string, unknown>) => Promise<CallToolResult>;
190
+ }
191
+ /**
192
+ * A tool as `defineMcpxTool` takes it. `Confirm` is inferred from `confirm`,
193
+ * so only a tool that sets it gets `confirmation` in its handler.
194
+ */
195
+ export type McpxToolDefinition<Shape extends z.ZodRawShape, Args, Confirm extends McpxToolConfirm | undefined> = McpxToolBase<Shape> & {
196
+ /**
197
+ * Internal: only builtin tools are confirmable for now. The handler gets
198
+ * `confirmation`: a call outside a confirmed run stores itself through
199
+ * `confirmation.request`, the key's user approves it in the admin panel,
200
+ * and `runConfirmed` runs the handler again with `confirmation.confirmed`
201
+ * set.
202
+ */
203
+ confirm?: Confirm;
204
+ handler(ctx: McpxToolContext<Args> & (Confirm extends McpxToolConfirm ? {
205
+ confirmation: McpxConfirmation;
206
+ } : unknown)): CallToolResult | Promise<CallToolResult>;
207
+ };
208
+ /**
209
+ * A tool, builtin or custom. Runs with `req.user` resolved from the key and
210
+ * `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
211
+ * per request, leaving no static shape to infer from.
212
+ */
213
+ export type McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> = McpxToolDefinition<Shape, Args, undefined> | (McpxToolDefinition<Shape, Args, McpxToolConfirm> & {
214
+ confirm: McpxToolConfirm;
215
+ });
216
+ /**
217
+ * How the admin panel shows one stored call of a confirmable tool.
218
+ */
219
+ export interface McpxConfirmationSummary {
220
+ /**
221
+ * What the call acts on, such as a collection's label.
222
+ */
223
+ label: string;
224
+ /**
225
+ * `undefined` when the target cannot be read.
226
+ */
227
+ title?: string;
228
+ /**
229
+ * The target's `_status`, where it has drafts.
230
+ */
231
+ status?: string;
232
+ id?: string;
233
+ /**
234
+ * Admin path that opens the target.
235
+ */
236
+ href?: string;
237
+ /**
238
+ * The call cannot be undone.
239
+ */
240
+ permanent: boolean;
241
+ /**
242
+ * Written by the client, so shown as such.
243
+ */
244
+ reason?: string;
245
+ }
246
+ /**
247
+ * Internal for now; see {@link McpxTool.confirm}.
248
+ */
249
+ export interface McpxToolConfirm {
250
+ /**
251
+ * Heading of the tool's group in the admin panel.
252
+ */
253
+ label: string;
254
+ /**
255
+ * Read at view time with the access of the key's user.
256
+ */
257
+ summarize(call: {
258
+ args: Record<string, unknown>;
259
+ }, req: PayloadRequest): Promise<McpxConfirmationSummary>;
157
260
  }
158
261
  /**
159
262
  * Argument type erased, so a registry can hold tools of differing shapes.
@@ -251,6 +354,14 @@ export interface McpxCollectionCapabilities {
251
354
  * Only ever true where the config lets writes publish and drafts exist.
252
355
  */
253
356
  publish: boolean;
357
+ /**
358
+ * Always `false` on a global.
359
+ */
360
+ delete: boolean;
361
+ /**
362
+ * Moves to trash without approval. Only ever true where `delete` is.
363
+ */
364
+ deleteUnattended: boolean;
254
365
  }
255
366
  /**
256
367
  * In force for one request: the plugin config and the key checkboxes together.
@@ -1,9 +1,12 @@
1
+ import { grantKvSlug, issueGrant } from "../grants/grant.mjs";
1
2
  import { errorResult, jsonResult } from "../result.mjs";
2
- import { issueGrant, uploadKvSlug } from "./grant.mjs";
3
+ import { SchemaError } from "../schema/errors.mjs";
4
+ import { resolveDataPointer } from "../schema/pointer.mjs";
5
+ import "../schema/index.mjs";
3
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
7
+ import { z } from "zod";
4
8
  import { APIError } from "payload";
5
9
  import { hasDraftValidationEnabled, hasDraftsEnabled, validateMimeType } from "payload/shared";
6
- import { z } from "zod";
7
10
  //#region src/upload/file.ts
8
11
  const MAX_BYTES = 26214400;
9
12
  const FILENAME = /^[^/\\\p{Cc}]+$/u;
@@ -77,22 +80,38 @@ const sharesPublishedFile = async (req, entity, id) => {
77
80
  })]);
78
81
  return latest?.["_status"] === "draft" && published?.["_status"] === "published" && latest["filename"] === published["filename"];
79
82
  };
83
+ const isClientWritable = (scope, target, path) => {
84
+ try {
85
+ const resolution = resolveDataPointer(scope.req.payload.config, {
86
+ ref: target.entity,
87
+ doc: target.data,
88
+ pointer: path
89
+ });
90
+ return resolution.readOnly !== true && resolution.descriptor?.readOnly !== true;
91
+ } catch (error) {
92
+ if (error instanceof SchemaError) return false;
93
+ throw error;
94
+ }
95
+ };
80
96
  /**
81
97
  * Refuses a file write Payload would turn into data loss: a replace that
82
98
  * deletes the published file, or a write that fails validation after the old
83
99
  * file is gone. Payload validates only after it moves files, so the merged
84
- * data is validated here first wherever Payload will validate it.
100
+ * data is validated here first wherever Payload will validate it, keeping only
101
+ * failures the client can fix. Once the bytes are attached, Payload validates
102
+ * with every hook and the file.
85
103
  */ const fileWriteRefusal = async (scope, target) => {
86
104
  const { entity, id } = target;
87
105
  if (id !== void 0 && await sharesPublishedFile(scope.req, entity, id)) return errorResult("The file was not replaced: the latest draft still uses the published file, which the replace would delete. Publish or discard the draft first.");
88
- if (hasDraftsEnabled(entity.config) && !hasDraftValidationEnabled(entity.config)) return;
89
- const { blockers } = await collectPublishBlockers(scope.req, {
106
+ if (uploadedFile(scope.req) !== void 0 || hasDraftsEnabled(entity.config) && !hasDraftValidationEnabled(entity.config)) return;
107
+ const { blockers: found } = await collectPublishBlockers(scope.req, {
90
108
  doc: {
91
109
  ...target.data,
92
110
  ...id === void 0 ? {} : { id }
93
111
  },
94
112
  entity
95
113
  });
114
+ const blockers = found.filter((blocker) => isClientWritable(scope, target, blocker.path));
96
115
  return blockers.length === 0 ? void 0 : errorResult("Nothing was written: the document fails validation.", { validationErrors: blockers });
97
116
  };
98
117
  const handoffUrl = (req, path) => {
@@ -102,7 +121,7 @@ const handoffUrl = (req, path) => {
102
121
  return url.toString();
103
122
  };
104
123
  const grantContext = (req) => {
105
- const slug = uploadKvSlug(req.payload.config);
124
+ const slug = grantKvSlug(req.payload.config);
106
125
  const apiKeyId = req.context.mcpx?.apiKeyId;
107
126
  if (slug === void 0 || apiKeyId === void 0) throw new Error("A handoff was offered without a database KV or key.");
108
127
  return {
@@ -1,4 +1,4 @@
1
- import { hasPublishIntent, takePublishIntent } from "./publish-intent.mjs";
1
+ import { hasPublishIntent, hasTrashIntent, takePublishIntent, takeTrashIntent, withTrashIntent } from "./publish-intent.mjs";
2
2
  import { isMcpxRequest } from "../request.mjs";
3
3
  import { APIError } from "payload";
4
4
  import { hasDraftsEnabled } from "payload/shared";
@@ -11,16 +11,17 @@ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
11
11
  "selectedLocales",
12
12
  "overwriteExistingFiles"
13
13
  ]);
14
- const scrubWriteArgs = (args, publishing) => {
14
+ const scrubWriteArgs = (args, intent) => {
15
+ const publishing = intent === "publish";
15
16
  const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key) && (publishing || key !== "publishSpecificLocale")));
16
17
  if (next["data"] && typeof next["data"] === "object") {
17
- const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
18
+ const { _status: _ignoredStatus, deletedAt, ...data } = next["data"];
18
19
  next["data"] = publishing ? {
19
20
  ...data,
20
21
  _status: "published"
21
- } : data;
22
+ } : intent === "trash" ? withTrashIntent({ deletedAt }) : data;
22
23
  }
23
- next["draft"] = !publishing;
24
+ next["draft"] = intent === void 0;
24
25
  next["autosave"] = false;
25
26
  next["overrideLock"] = false;
26
27
  next["trash"] = false;
@@ -29,8 +30,8 @@ const scrubWriteArgs = (args, publishing) => {
29
30
  const forceDraftWrite = (hookArgs) => {
30
31
  const { args, operation, req } = hookArgs;
31
32
  if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
32
- const publishing = operation === "update" && hasPublishIntent(args.data);
33
- return scrubWriteArgs(args, publishing);
33
+ const intent = operation !== "update" ? void 0 : hasPublishIntent(args.data) ? "publish" : hasTrashIntent(args.data) ? "trash" : void 0;
34
+ return scrubWriteArgs(args, intent);
34
35
  };
35
36
  /**
36
37
  * The global counterpart of {@link forceDraftWrite}. `updateGlobal` reads
@@ -46,11 +47,12 @@ const forceDraftWrite = (hookArgs) => {
46
47
  req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${global.slug} that did not ask for a draft save.`);
47
48
  throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
48
49
  }
49
- return scrubWriteArgs(args, publishing);
50
+ return scrubWriteArgs(args, publishing ? "publish" : void 0);
50
51
  };
51
52
  const refuseUnlessExpected = (req, slug, data) => {
52
53
  const publishing = takePublishIntent(data);
53
- if (!isMcpxRequest(req)) return;
54
+ const trashing = takeTrashIntent(data);
55
+ if (!isMcpxRequest(req) || trashing) return;
54
56
  const status = data._status;
55
57
  const expected = publishing ? "published" : "draft";
56
58
  if (status === expected) return;