@abinnovision/payloadcms-mcpx 2.0.0-beta.13 → 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 +3 -3
  37. package/dist/write/draft-guard.mjs +11 -9
  38. package/dist/write/publish-intent.mjs +16 -6
  39. package/package.json +1 -1
@@ -1,6 +1,13 @@
1
+ import { z } from "zod";
1
2
  //#region src/define-tool.ts
2
3
  function defineMcpxTool(tool) {
3
4
  return tool;
4
5
  }
6
+ /**
7
+ * Strict, so an unknown argument is rejected by name instead of stripped.
8
+ */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
9
+ /**
10
+ * A tool that does not decide for itself is gated by its own checkbox.
11
+ */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
5
12
  //#endregion
6
- export { defineMcpxTool };
13
+ export { defineMcpxTool, isToolEnabled, toolInputSchema };
@@ -0,0 +1,124 @@
1
+ import { isPlainObject } from "../guards.mjs";
2
+ import { CONFIRMATIONS_PATH } from "../api-keys/confirmation-view.mjs";
3
+ import { grantKvSlug } from "../grants/grant.mjs";
4
+ import { confirmationHandle, decideConfirmations, issueConfirmation, listConfirmations } from "../grants/confirmation.mjs";
5
+ import { jsonResult } from "../result.mjs";
6
+ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
7
+ import { formatAdminURL } from "payload/shared";
8
+ //#region src/endpoint/confirmations.ts
9
+ const respond = (status, body) => Response.json(body, { status });
10
+ const confirmableTool = (name) => BUILTIN_TOOLS.find((tool) => tool.name === name && tool.confirm);
11
+ const keyViewUrl = (req, options, apiKeyId) => {
12
+ const { config } = req.payload;
13
+ return formatAdminURL({
14
+ adminRoute: config.routes.admin,
15
+ path: `/collections/${options.apiKeysSlug}/${String(apiKeyId)}`,
16
+ serverURL: config.serverURL || new URL(req.url ?? "").origin
17
+ });
18
+ };
19
+ /**
20
+ * The `confirmation` a confirmable tool's handler gets on an MCP call. Its
21
+ * `request` stores the call for the key's user to approve and answers with
22
+ * the confirmation the client passes to `runConfirmed`.
23
+ */ const confirmationFor = (tool, scope, options) => ({
24
+ confirmed: false,
25
+ request: async (args) => {
26
+ const { req } = scope;
27
+ const slug = grantKvSlug(req.payload.config);
28
+ const apiKeyId = req.context.mcpx?.apiKeyId;
29
+ if (slug === void 0 || apiKeyId === void 0) throw new Error("A confirmation was requested without a database KV.");
30
+ const view = keyViewUrl(req, options, apiKeyId);
31
+ const { id, exp } = await issueConfirmation(req.payload, slug, {
32
+ apiKeyId,
33
+ tool,
34
+ args,
35
+ locale: req.locale ?? null,
36
+ fallbackLocale: req.fallbackLocale ?? null
37
+ });
38
+ return jsonResult({ confirmation: {
39
+ id,
40
+ url: `${view}?confirmation=${encodeURIComponent(id)}`,
41
+ expiresAt: new Date(exp).toISOString()
42
+ } });
43
+ }
44
+ });
45
+ const authorize = async (req, options, keyId) => {
46
+ const slug = grantKvSlug(req.payload.config);
47
+ if (slug === void 0) return respond(404, { error: "Confirmations are not available." });
48
+ if (req.user?.collection !== options.userCollection) return respond(401, { error: "Sign in as the user of this key." });
49
+ const key = typeof keyId === "string" || typeof keyId === "number" ? await req.payload.findByID({
50
+ collection: options.apiKeysSlug,
51
+ id: keyId,
52
+ depth: 0,
53
+ overrideAccess: true,
54
+ disableErrors: true,
55
+ select: { user: true }
56
+ }).catch(() => null) : null;
57
+ const user = isPlainObject(key?.user) ? key.user["id"] : key?.user;
58
+ if (!key || typeof user !== "string" && typeof user !== "number" || String(user) !== String(req.user.id)) return respond(403, { error: "Only the user of this key may decide on its calls." });
59
+ return {
60
+ slug,
61
+ apiKeyId: key.id
62
+ };
63
+ };
64
+ const viewOf = async (req, entry, highlight) => {
65
+ const { tool: name, args } = entry.confirmation;
66
+ const tool = confirmableTool(name);
67
+ const summary = await tool?.confirm?.summarize({ args }, req).catch((error) => {
68
+ req.payload.logger.warn({
69
+ err: error,
70
+ msg: "[payloadcms-mcpx] Could not summarize a confirmation."
71
+ });
72
+ });
73
+ return {
74
+ handle: entry.handle,
75
+ tool: name,
76
+ group: tool?.confirm?.label ?? name,
77
+ highlighted: entry.handle === highlight,
78
+ summary: summary ?? {
79
+ label: name,
80
+ permanent: true
81
+ }
82
+ };
83
+ };
84
+ /**
85
+ * Lists the pending calls of `?key=`, for its user's admin session. A
86
+ * `?confirmation=` id marks its call as highlighted.
87
+ */ const listHandler = (options) => async (req) => {
88
+ const allowed = await authorize(req, options, req.searchParams.get("key"));
89
+ if (allowed instanceof Response) return allowed;
90
+ const { payload } = req;
91
+ const highlighted = req.searchParams.get("confirmation");
92
+ const highlight = highlighted === null ? void 0 : confirmationHandle(payload, highlighted);
93
+ const entries = (await listConfirmations(payload, allowed.slug, allowed.apiKeyId)).filter((entry) => entry.confirmation.state === "pending");
94
+ const confirmations = [];
95
+ for (const entry of entries) confirmations.push(await viewOf(req, entry, highlight));
96
+ return respond(200, { confirmations });
97
+ };
98
+ /**
99
+ * Approves or rejects pending calls of a key, for its user's admin session.
100
+ * The body is `{ key, handles, decision }`. Answers with the handles it
101
+ * changed; a call already decided, run or expired is left out.
102
+ */ const decideHandler = (options) => async (req) => {
103
+ const body = await req.json?.().catch(() => void 0);
104
+ const allowed = await authorize(req, options, isPlainObject(body) ? body["key"] : void 0);
105
+ if (allowed instanceof Response) return allowed;
106
+ const handles = isPlainObject(body) ? body["handles"] : void 0;
107
+ const decision = isPlainObject(body) ? body["decision"] : void 0;
108
+ if (!Array.isArray(handles) || handles.length > 50 || !handles.every((handle) => typeof handle === "string") || decision !== "approved" && decision !== "rejected") return respond(400, { error: `Send "handles", at most ${String(50)}, and a "decision" of "approved" or "rejected".` });
109
+ const decided = await decideConfirmations(req.payload, allowed.slug, allowed.apiKeyId, handles, decision);
110
+ return respond(200, { decided });
111
+ };
112
+ /**
113
+ * The endpoints the API key's edit view reads and decides through.
114
+ */ const confirmationEndpoints = (options) => [{
115
+ path: `${options.endpointPath}${CONFIRMATIONS_PATH}`,
116
+ method: "get",
117
+ handler: listHandler(options)
118
+ }, {
119
+ path: `${options.endpointPath}${CONFIRMATIONS_PATH}`,
120
+ method: "post",
121
+ handler: decideHandler(options)
122
+ }];
123
+ //#endregion
124
+ export { confirmationEndpoints, confirmationFor };
@@ -1,7 +1,7 @@
1
1
  import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
2
+ import { grantKvSlug } from "../grants/grant.mjs";
2
3
  import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
3
4
  import { jsonRpcError } from "./errors.mjs";
4
- import { uploadKvSlug } from "../upload/grant.mjs";
5
5
  import { createMcpServer } from "./server.mjs";
6
6
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7
7
  //#region src/endpoint/handler.ts
@@ -35,7 +35,7 @@ const readBody = async (req) => {
35
35
  defaultLocale: localization.defaultLocale
36
36
  } : null,
37
37
  limits: options.limits,
38
- uploads: options.auth?.resolve === void 0 && uploadKvSlug(req.payload.config) !== void 0,
38
+ uploads: options.auth?.resolve === void 0 && grantKvSlug(req.payload.config) !== void 0,
39
39
  diagnostics: options.diagnostics ? {
40
40
  name: options.serverInfo.name,
41
41
  version: options.serverInfo.version
@@ -1,29 +1,33 @@
1
- import { toToolError } from "./errors.mjs";
1
+ import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
2
  import { fileSlugs } from "../upload/file.mjs";
3
3
  import { liveWriteSlugs } from "../tools/shared.mjs";
4
4
  import { MCPX_REPOSITORY_URL } from "../version.mjs";
5
5
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
6
+ import { confirmationFor } from "./confirmations.mjs";
7
+ import { toToolError } from "./errors.mjs";
6
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
- import { z } from "zod";
8
9
  //#region src/endpoint/server.ts
9
- /**
10
- * Strict, so an unknown argument is rejected by name instead of stripped.
11
- */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
12
10
  const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
13
- /**
14
- * A tool that does not decide for itself is gated by its own checkbox.
15
- */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
11
+ const NO_CONFIRMATION = {
12
+ confirmed: false,
13
+ request: () => Promise.reject(/* @__PURE__ */ new Error("No confirmation can be stored here."))
14
+ };
16
15
  /**
17
16
  * Runs one tool call. Payload's public errors become error results; anything
18
- * else is logged and reported as an internal error.
19
- */ const runTool = async (tool, args, scope, req, extra) => {
17
+ * else is logged and reported as an internal error. A tool with `confirm`
18
+ * gets `confirmation`.
19
+ */ const runTool = async (tool, args, scope, req, extra, confirmation = NO_CONFIRMATION) => {
20
+ const ctx = {
21
+ args,
22
+ scope,
23
+ req,
24
+ extra
25
+ };
20
26
  try {
21
- return await tool.handler({
22
- args,
23
- scope,
24
- req,
25
- extra
26
- });
27
+ return await (tool.confirm ? tool.handler({
28
+ ...ctx,
29
+ confirmation
30
+ }) : tool.handler(ctx));
27
31
  } catch (error) {
28
32
  return toToolError(error, req.payload.logger);
29
33
  }
@@ -96,7 +100,7 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
96
100
  inputSchema: toolInputSchema(tool, scope),
97
101
  ...tool.annotations ? { annotations: tool.annotations } : {}
98
102
  }, (args, extra) => {
99
- const result = queue.then(async () => await runTool(tool, args, scope, req, extra));
103
+ const result = queue.then(async () => await runTool(tool, args, scope, req, extra, confirmationFor(tool.name, scope, options)));
100
104
  queue = result.catch(() => void 0);
101
105
  return result;
102
106
  });
@@ -104,4 +108,4 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
104
108
  return server;
105
109
  };
106
110
  //#endregion
107
- export { createMcpServer, isToolEnabled, runTool, toolInputSchema };
111
+ export { createMcpServer, runTool };
@@ -1,8 +1,9 @@
1
- import { checkApiKey } from "../auth/resolve.mjs";
2
- import { claimGrant, uploadKvSlug } from "../upload/grant.mjs";
1
+ import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
+ import { claimGrant, grantKvSlug } from "../grants/grant.mjs";
3
3
  import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
4
4
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
5
- import { isToolEnabled, runTool, toolInputSchema } from "./server.mjs";
5
+ import { checkApiKey } from "../auth/resolve.mjs";
6
+ import { runTool } from "./server.mjs";
6
7
  import { authenticateAs } from "./handler.mjs";
7
8
  //#region src/endpoint/upload.ts
8
9
  const UPLOAD_TOOLS = /* @__PURE__ */ new Set(["createDocument", "patchDocument"]);
@@ -54,7 +55,7 @@ const reauthenticate = async (req, options, grant) => {
54
55
  return await checkApiKey(req, options, keyDoc ?? void 0);
55
56
  };
56
57
  const claimAs = async (req, options, kind) => {
57
- const slug = uploadKvSlug(req.payload.config);
58
+ const slug = grantKvSlug(req.payload.config);
58
59
  const grantId = req.headers.get("x-mcpx-grant");
59
60
  if (slug === void 0 || options.auth?.resolve || grantId === null) return;
60
61
  const grant = await claimGrant(req.payload, slug, grantId);
@@ -0,0 +1,106 @@
1
+ import { isPlainObject } from "../guards.mjs";
2
+ import { hashApiKey } from "../api-keys/key.mjs";
3
+ import { GRANT_ID, NO_REQ, expOf, issueRow, rowsWith } from "./grant.mjs";
4
+ import { ValidationError } from "payload";
5
+ //#region src/grants/confirmation.ts
6
+ const CONFIRMATION_TTL_MS = 9e5;
7
+ const HANDLE = /^[\da-f]{64}$/;
8
+ const entryPrefix = (apiKeyId) => `mcpx-confirmation:${String(apiKeyId)}:`;
9
+ const usedPrefix = (apiKeyId) => `mcpx-confirmation-used:${String(apiKeyId)}:`;
10
+ const asConfirmation = (row, now) => row && isPlainObject(row.data) && expOf(row.data) > now ? row.data : void 0;
11
+ /**
12
+ * The handle the admin panel uses for the entry of `id`: its HMAC, which is
13
+ * also what the row key holds.
14
+ */ const confirmationHandle = (payload, id) => hashApiKey(payload.secret, id);
15
+ /**
16
+ * Stores a pending call and returns its id. A key may hold at most
17
+ * {@link CONFIRMATIONS_PER_KEY} calls.
18
+ */ const issueConfirmation = async (payload, slug, call) => {
19
+ const entry = {
20
+ ...call,
21
+ state: "pending"
22
+ };
23
+ return await issueRow(payload, slug, {
24
+ prefix: entryPrefix(call.apiKeyId),
25
+ usedPrefix: usedPrefix(call.apiKeyId),
26
+ cap: 50,
27
+ capMessage: `This key already has ${String(50)} calls waiting for approval. Run or abandon them first; each expires 15 minutes after it was requested.`,
28
+ ttlMs: CONFIRMATION_TTL_MS,
29
+ data: entry
30
+ });
31
+ };
32
+ /**
33
+ * The unexpired call of `apiKeyId` with `id`, without claiming it. A call of
34
+ * another key is `undefined`, as is a malformed id, before any database read.
35
+ */ const readConfirmation = async (payload, slug, apiKeyId, id) => {
36
+ if (!GRANT_ID.test(id)) return;
37
+ const key = `${entryPrefix(apiKeyId)}${confirmationHandle(payload, id)}`;
38
+ const rows = await rowsWith(payload, slug, [key]);
39
+ return asConfirmation(rows.find((row) => row.key === key), Date.now());
40
+ };
41
+ /**
42
+ * Marks the call used and removes it. The marker is a unique-key insert, so
43
+ * of two concurrent claims exactly one returns `true`.
44
+ */ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) => {
45
+ const handle = confirmationHandle(payload, id);
46
+ try {
47
+ await payload.db.create({
48
+ collection: slug,
49
+ data: {
50
+ key: `${usedPrefix(apiKeyId)}${handle}`,
51
+ data: { exp }
52
+ },
53
+ req: NO_REQ
54
+ });
55
+ } catch (error) {
56
+ if (error instanceof ValidationError) return false;
57
+ throw error;
58
+ }
59
+ await payload.db.deleteOne({
60
+ collection: slug,
61
+ where: { key: { equals: `${entryPrefix(apiKeyId)}${handle}` } },
62
+ req: NO_REQ
63
+ });
64
+ return true;
65
+ };
66
+ /**
67
+ * Every unexpired call of `apiKeyId` with its handle, oldest first.
68
+ */ const listConfirmations = async (payload, slug, apiKeyId) => {
69
+ const prefix = entryPrefix(apiKeyId);
70
+ const now = Date.now();
71
+ return (await rowsWith(payload, slug, [prefix])).flatMap((row) => {
72
+ const confirmation = asConfirmation(row, now);
73
+ return confirmation ? [{
74
+ handle: row.key.slice(prefix.length),
75
+ confirmation
76
+ }] : [];
77
+ }).sort((left, right) => left.confirmation.exp - right.confirmation.exp);
78
+ };
79
+ /**
80
+ * Approves or rejects the pending calls of `apiKeyId` among `handles`, and
81
+ * returns the handles it changed. A call that was decided, run or expired is
82
+ * left alone and not returned.
83
+ */ const decideConfirmations = async (payload, slug, apiKeyId, handles, state) => {
84
+ const prefix = entryPrefix(apiKeyId);
85
+ const keys = new Set(handles.filter((handle) => HANDLE.test(handle)).map((handle) => `${prefix}${handle}`));
86
+ if (keys.size === 0) return [];
87
+ const now = Date.now();
88
+ const decided = [];
89
+ for (const row of await rowsWith(payload, slug, [prefix])) {
90
+ const confirmation = asConfirmation(row, now);
91
+ if (!keys.has(row.key) || confirmation?.state !== "pending") continue;
92
+ const updated = await payload.db.updateMany({
93
+ collection: slug,
94
+ where: { and: [{ key: { equals: row.key } }, { "data.state": { equals: "pending" } }] },
95
+ data: { data: {
96
+ ...confirmation,
97
+ state
98
+ } },
99
+ req: NO_REQ
100
+ });
101
+ if (updated !== null && updated.length > 0) decided.push(row.key.slice(prefix.length));
102
+ }
103
+ return decided;
104
+ };
105
+ //#endregion
106
+ export { claimConfirmation, confirmationHandle, decideConfirmations, issueConfirmation, listConfirmations, readConfirmation };
@@ -2,7 +2,7 @@ import { isPlainObject } from "../guards.mjs";
2
2
  import { hashApiKey } from "../api-keys/key.mjs";
3
3
  import { APIError, ValidationError } from "payload";
4
4
  import crypto from "node:crypto";
5
- //#region src/upload/grant.ts
5
+ //#region src/grants/grant.ts
6
6
  const GRANT_TTL_MS = 3e5;
7
7
  const GRANTS_PER_KEY = 10;
8
8
  const GRANT_ID = /^[\w-]{43}$/;
@@ -13,12 +13,14 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
13
13
  /**
14
14
  * The KV collection slug, when Payload's database KV adapter backs it with a
15
15
  * unique `key`, which the single-use claim relies on. `undefined` otherwise.
16
- */ const uploadKvSlug = (config) => {
16
+ */ const grantKvSlug = (config) => {
17
17
  const kv = config.kv;
18
18
  const key = kv?.kvCollection?.fields.find((field) => "name" in field && field.name === "key");
19
19
  return key && "unique" in key && key.unique ? kv?.kvCollection?.slug : void 0;
20
20
  };
21
- const findRows = async (payload, slug, fragments) => {
21
+ /**
22
+ * KV rows whose key contains one of `fragments`.
23
+ */ const findRows = async (payload, slug, fragments) => {
22
24
  const { docs } = await payload.db.find({
23
25
  collection: slug,
24
26
  where: { or: fragments.map((fragment) => ({ key: { like: fragment } })) },
@@ -29,35 +31,56 @@ const findRows = async (payload, slug, fragments) => {
29
31
  return docs;
30
32
  };
31
33
  /**
32
- * Stores a grant and returns its id, the only copy of which goes to the
33
- * client. The row key holds the HMAC of the id. Expired rows of the same key
34
- * are purged first, and a key may hold at most {@link GRANTS_PER_KEY} grants.
35
- */ const issueGrant = async (payload, slug, grant) => {
34
+ * KV rows whose key starts with one of `prefixes`. `like` matches a
35
+ * substring, so the prefix is checked again here.
36
+ */ const rowsWith = async (payload, slug, prefixes) => (await findRows(payload, slug, prefixes)).filter((row) => prefixes.some((prefix) => row.key.startsWith(prefix)));
37
+ /**
38
+ * Stores `data` under `prefix` and the HMAC of a fresh id, and returns the id,
39
+ * the only copy of which goes to the client. Expired rows under `prefix` and
40
+ * `usedPrefix` are purged first, and at most `cap` unexpired rows may wait
41
+ * under `prefix`; past that, `capMessage` is thrown with 429.
42
+ */ const issueRow = async (payload, slug, args) => {
36
43
  const now = Date.now();
37
- const prefixes = [grantPrefix(grant.apiKeyId), usedPrefix(grant.apiKeyId)];
38
- const rows = (await findRows(payload, slug, prefixes)).filter((row) => prefixes.some((prefix) => row.key.startsWith(prefix)));
44
+ const rows = await rowsWith(payload, slug, [args.prefix, args.usedPrefix]);
39
45
  const expired = rows.filter((row) => expOf(row.data) <= now);
40
46
  if (expired.length > 0) await payload.db.deleteMany({
41
47
  collection: slug,
42
48
  where: { key: { in: expired.map((row) => row.key) } },
43
49
  req: NO_REQ
44
50
  });
45
- if (rows.filter((row) => row.key.startsWith(prefixes[0]) && expOf(row.data) > now).length >= GRANTS_PER_KEY) throw new APIError(`This key already has ${String(GRANTS_PER_KEY)} uploads or downloads waiting. Use or abandon them first; each expires 5 minutes after it was issued.`, 429);
46
- const grantId = crypto.randomBytes(32).toString("base64url");
47
- const exp = now + GRANT_TTL_MS;
51
+ if (rows.filter((row) => row.key.startsWith(args.prefix) && expOf(row.data) > now).length >= args.cap) throw new APIError(args.capMessage, 429);
52
+ const id = crypto.randomBytes(32).toString("base64url");
53
+ const exp = now + args.ttlMs;
48
54
  await payload.db.create({
49
55
  collection: slug,
50
56
  data: {
51
- key: `${grantPrefix(grant.apiKeyId)}${hashApiKey(payload.secret, grantId)}`,
57
+ key: `${args.prefix}${hashApiKey(payload.secret, id)}`,
52
58
  data: {
53
- ...grant,
59
+ ...args.data,
54
60
  exp
55
61
  }
56
62
  },
57
63
  req: NO_REQ
58
64
  });
59
65
  return {
60
- grantId,
66
+ id,
67
+ exp
68
+ };
69
+ };
70
+ /**
71
+ * Stores a grant and returns its id. A key may hold at most
72
+ * {@link GRANTS_PER_KEY} grants.
73
+ */ const issueGrant = async (payload, slug, grant) => {
74
+ const { id, exp } = await issueRow(payload, slug, {
75
+ prefix: grantPrefix(grant.apiKeyId),
76
+ usedPrefix: usedPrefix(grant.apiKeyId),
77
+ cap: GRANTS_PER_KEY,
78
+ capMessage: `This key already has ${String(GRANTS_PER_KEY)} uploads or downloads waiting. Use or abandon them first; each expires 5 minutes after it was issued.`,
79
+ ttlMs: GRANT_TTL_MS,
80
+ data: grant
81
+ });
82
+ return {
83
+ grantId: id,
61
84
  exp
62
85
  };
63
86
  };
@@ -93,4 +116,4 @@ const findRows = async (payload, slug, fragments) => {
93
116
  return grant;
94
117
  };
95
118
  //#endregion
96
- export { claimGrant, issueGrant, uploadKvSlug };
119
+ export { GRANT_ID, NO_REQ, claimGrant, expOf, grantKvSlug, issueGrant, issueRow, rowsWith };
package/dist/options.mjs CHANGED
@@ -21,6 +21,12 @@ const assertExposable = (collection, apiKeysSlug) => {
21
21
  if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
22
22
  };
23
23
  const OPTION_NAMES = [
24
+ "read",
25
+ "write",
26
+ "publish",
27
+ "delete"
28
+ ];
29
+ const GLOBAL_OPTION_NAMES = [
24
30
  "read",
25
31
  "write",
26
32
  "publish"
@@ -37,13 +43,25 @@ const readFlag = (kind, slug, name, value) => {
37
43
  const assertWritable = (kind, config, write) => {
38
44
  if (write === "live" && hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which live writes do not support yet. Set publish: false or write: false.`);
39
45
  };
46
+ const normalizeDelete = (kind, config, value) => {
47
+ if (value !== "unattended") return {
48
+ delete: readFlag(kind, config.slug, "delete", value) ?? false,
49
+ deleteUnattended: false
50
+ };
51
+ if (!("trash" in config) || !config.trash) fail(`${kind} "${config.slug}" has delete: "unattended" but no trash, so a delete without approval would be permanent. Enable trash or set delete: true.`);
52
+ return {
53
+ delete: true,
54
+ deleteUnattended: true
55
+ };
56
+ };
40
57
  const normalizeCapabilities = (kind, config, raw) => {
41
58
  const { slug } = config;
59
+ const names = kind === "Global" ? GLOBAL_OPTION_NAMES : OPTION_NAMES;
42
60
  if (raw === false) fail(`${kind} "${slug}" is set to false. Remove the entry to hide the ${kind.toLowerCase()}.`);
43
- if (raw !== true && !isPlainObject(raw)) fail(`${kind} "${slug}" has ${JSON.stringify(raw)}. Use true or an object of ${OPTION_NAMES.join(", ")}.`);
61
+ if (raw !== true && !isPlainObject(raw)) fail(`${kind} "${slug}" has ${JSON.stringify(raw)}. Use true or an object of ${names.join(", ")}.`);
44
62
  const settings = isPlainObject(raw) ? raw : {};
45
- const unknownKey = Object.keys(settings).find((key) => !OPTION_NAMES.includes(key));
46
- if (unknownKey !== void 0) fail(`${kind} "${slug}" has the unknown option "${unknownKey}". The options are ${OPTION_NAMES.join(", ")}.`);
63
+ const unknownKey = Object.keys(settings).find((key) => !names.includes(key));
64
+ if (unknownKey !== void 0) fail(`${kind} "${slug}" has the unknown option "${unknownKey}". The options are ${names.join(", ")}.`);
47
65
  const flag = (name) => readFlag(kind, slug, name, settings[name]);
48
66
  const read = flag("read") ?? true;
49
67
  const write = flag("write") ?? true;
@@ -56,7 +74,8 @@ const normalizeCapabilities = (kind, config, raw) => {
56
74
  read,
57
75
  write: write ? publish === false ? "draft" : "live" : false,
58
76
  hasDrafts,
59
- hasVersions: read && Boolean(config.versions)
77
+ hasVersions: read && Boolean(config.versions),
78
+ ...normalizeDelete(kind, config, settings["delete"])
60
79
  };
61
80
  };
62
81
  const assertGlobalExposable = (global) => {
@@ -138,8 +157,11 @@ const normalizeLimits = (limits) => {
138
157
  assertUserCollection(config, userCollection);
139
158
  const tools = options.tools ?? [];
140
159
  assertTools(tools);
160
+ const collections = normalizeCollections(config, options, apiKeysSlug);
161
+ const confirmations = collections.some((entity) => entity.delete);
162
+ if (confirmations && options.auth?.resolve) fail("delete needs approval on the API key document, which a custom auth.resolve does not support. Remove delete: true or auth.resolve.");
141
163
  return {
142
- collections: normalizeCollections(config, options, apiKeysSlug),
164
+ collections,
143
165
  globals: normalizeGlobals(config, options),
144
166
  userCollection,
145
167
  apiKeysSlug,
@@ -147,6 +169,7 @@ const normalizeLimits = (limits) => {
147
169
  setupGuide: options.apiKeys?.setupGuide ?? true,
148
170
  limits: normalizeLimits(options.limits),
149
171
  tools,
172
+ confirmations,
150
173
  auth: options.auth,
151
174
  serverInfo: {
152
175
  name: options.serverInfo?.name ?? "payloadcms-mcpx",
package/dist/plugin.d.mts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { McpxPluginOptions } from "./types.mjs";
2
2
  //#region src/plugin.d.ts
3
3
  /**
4
- * Mounts the MCP, upload and download endpoints, adds the API key collection
4
+ * Mounts the MCP, upload and download endpoints, and the confirmation
5
+ * endpoints where a collection exposes `delete`. Adds the API key collection
5
6
  * and installs the draft guard on every collection and global.
6
7
  */
7
8
  export declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
package/dist/plugin.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
+ import { confirmationEndpoints } from "./endpoint/confirmations.mjs";
2
3
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
4
  import { createDownloadHandler, createUploadHandler } from "./endpoint/upload.mjs";
4
5
  import { normalizeOptions } from "./options.mjs";
@@ -6,7 +7,8 @@ import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guar
6
7
  import { definePlugin } from "payload";
7
8
  //#region src/plugin.ts
8
9
  /**
9
- * Mounts the MCP, upload and download endpoints, adds the API key collection
10
+ * Mounts the MCP, upload and download endpoints, and the confirmation
11
+ * endpoints where a collection exposes `delete`. Adds the API key collection
10
12
  * and installs the draft guard on every collection and global.
11
13
  */ const mcpxPlugin = definePlugin({
12
14
  slug: "@abinnovision/payloadcms-mcpx",
@@ -45,7 +47,8 @@ import { definePlugin } from "payload";
45
47
  path: `${normalized.endpointPath}/file`,
46
48
  method: "get",
47
49
  handler: createDownloadHandler(normalized)
48
- }
50
+ },
51
+ ...normalized.confirmations ? confirmationEndpoints(normalized) : []
49
52
  ]
50
53
  };
51
54
  }
@@ -1,4 +1,5 @@
1
1
  import { createDocument } from "./create-document.mjs";
2
+ import { deleteDocument } from "./delete-document.mjs";
2
3
  import { describeSchema } from "./describe-schema.mjs";
3
4
  import { findDocuments } from "./find-documents.mjs";
4
5
  import { findVersions } from "./find-versions.mjs";
@@ -6,6 +7,7 @@ import { getDocument } from "./get-document.mjs";
6
7
  import { listCapabilities } from "./list-capabilities.mjs";
7
8
  import { patchDocument } from "./patch-document.mjs";
8
9
  import { publishDocument } from "./publish-document.mjs";
10
+ import { createRunConfirmed } from "./run-confirmed.mjs";
9
11
  import { validateDocument } from "./validate-document.mjs";
10
12
  //#region src/tools/builtin.ts
11
13
  /**
@@ -22,7 +24,9 @@ import { validateDocument } from "./validate-document.mjs";
22
24
  patchDocument,
23
25
  createDocument,
24
26
  validateDocument,
25
- publishDocument
27
+ publishDocument,
28
+ deleteDocument,
29
+ createRunConfirmed(() => BUILTIN_TOOLS)
26
30
  ];
27
31
  //#endregion
28
32
  export { BUILTIN_TOOLS };
@@ -7,8 +7,8 @@ import { assertAcceptsFile, fileShape, fileWriteRefusal, uploadHandoff, uploaded
7
7
  import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
8
8
  import { readDraft, resolveEntity } from "./document.mjs";
9
9
  import { stripRowIds } from "../write/row-ids.mjs";
10
- import { APIError } from "payload";
11
10
  import { z } from "zod";
11
+ import { APIError } from "payload";
12
12
  //#region src/tools/create-document.ts
13
13
  const uploadSentence = (scope) => {
14
14
  const slugs = patchOnlySlugs(scope);