@abinnovision/payloadcms-mcpx 2.0.0-beta.13 → 2.0.0-beta.15
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.
- package/README.md +50 -35
- package/dist/admin/capability-matrix.d.mts +4 -3
- package/dist/admin/capability-matrix.mjs +262 -111
- package/dist/admin/capability-toggles.mjs +84 -40
- package/dist/admin/confirmations-panel.mjs +242 -0
- package/dist/admin/confirmations.d.mts +15 -0
- package/dist/admin/confirmations.mjs +70 -0
- package/dist/admin/index.d.mts +2 -1
- package/dist/admin/index.mjs +2 -1
- package/dist/api-keys/capability-matrix.d.mts +5 -4
- package/dist/api-keys/capability-matrix.mjs +33 -12
- package/dist/api-keys/collection.mjs +3 -3
- package/dist/api-keys/confirmation-view.mjs +6 -0
- package/dist/api-keys/fields.mjs +20 -3
- package/dist/builtin-tool-names.mjs +3 -1
- package/dist/capabilities.mjs +9 -4
- package/dist/define-tool.d.mts +4 -4
- package/dist/define-tool.mjs +8 -1
- package/dist/endpoint/confirmations.mjs +124 -0
- package/dist/endpoint/handler.mjs +2 -2
- package/dist/endpoint/server.mjs +22 -18
- package/dist/endpoint/upload.mjs +5 -4
- package/dist/grants/confirmation.mjs +106 -0
- package/dist/{upload → grants}/grant.mjs +39 -16
- package/dist/options.mjs +32 -6
- package/dist/plugin.d.mts +2 -1
- package/dist/plugin.mjs +5 -2
- package/dist/tools/builtin.mjs +5 -1
- package/dist/tools/create-document.mjs +1 -1
- package/dist/tools/delete-document.mjs +157 -0
- package/dist/tools/find-versions.mjs +1 -1
- package/dist/tools/get-document.mjs +1 -1
- package/dist/tools/list-capabilities.mjs +2 -2
- package/dist/tools/patch-document.mjs +2 -2
- package/dist/tools/publish-document.mjs +1 -1
- package/dist/tools/run-confirmed.mjs +126 -0
- package/dist/tools/shared.mjs +4 -0
- package/dist/tools/validate-document.mjs +2 -2
- package/dist/types.d.mts +130 -19
- package/dist/upload/file.mjs +3 -3
- package/dist/write/draft-guard.mjs +11 -9
- package/dist/write/publish-intent.mjs +16 -6
- package/package.json +1 -1
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,
|
|
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,
|
|
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
|
}
|
package/dist/tools/builtin.mjs
CHANGED
|
@@ -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);
|
|
@@ -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.";
|
|
@@ -37,7 +37,7 @@ const translateLabel = (scope, label, fallback) => {
|
|
|
37
37
|
const collections = scope.exposure.collections.flatMap((entry) => {
|
|
38
38
|
const capability = scope.capabilities.collections[entry.slug];
|
|
39
39
|
const collection = payload.collections[entry.slug];
|
|
40
|
-
if (!capability || !collection || !
|
|
40
|
+
if (!capability || !collection || !capability.read) return [];
|
|
41
41
|
const { config } = collection;
|
|
42
42
|
const description = translate(config.admin.description);
|
|
43
43
|
return [{
|
|
@@ -60,7 +60,7 @@ const translateLabel = (scope, label, fallback) => {
|
|
|
60
60
|
const globals = scope.exposure.globals.flatMap((entry) => {
|
|
61
61
|
const capability = scope.capabilities.globals[entry.slug];
|
|
62
62
|
const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
|
|
63
|
-
if (!capability || !config || !
|
|
63
|
+
if (!capability || !config || !capability.read) return [];
|
|
64
64
|
const description = translate(config.admin.description);
|
|
65
65
|
return [{
|
|
66
66
|
slug: entry.slug,
|
|
@@ -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 };
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -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 {
|
|
@@ -4,8 +4,8 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
|
4
4
|
import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
|
|
5
5
|
import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from "./document.mjs";
|
|
6
6
|
/**
|
|
7
|
-
* Gated on write
|
|
8
|
-
* to a caller who can act on them.
|
|
7
|
+
* Gated on write, which implies read, because publish blockers only mean
|
|
8
|
+
* something to a caller who can act on them.
|
|
9
9
|
*
|
|
10
10
|
* It reads the document twice on purpose: once under the key's own access to
|
|
11
11
|
* refuse a caller who may not see it, then privileged, so the check covers every
|
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
|
|
@@ -36,7 +36,7 @@ export interface McpxCollectionOptions {
|
|
|
36
36
|
/**
|
|
37
37
|
* Expose `patchDocument`, `validateDocument` and `createDocument`. An upload
|
|
38
38
|
* collection is created in only where MCP accepts its files. Without drafts
|
|
39
|
-
* a write changes live content. Default `true
|
|
39
|
+
* a write changes live content. Needs `read`. Default `true` while `read` is.
|
|
40
40
|
*/
|
|
41
41
|
write?: boolean;
|
|
42
42
|
/**
|
|
@@ -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 set to it move
|
|
52
|
+
* documents to trash without approval; it needs `trash`. Needs `read`.
|
|
53
|
+
* Default `false`.
|
|
54
|
+
*/
|
|
55
|
+
delete?: boolean | "unattended";
|
|
48
56
|
}
|
|
49
57
|
/**
|
|
50
|
-
* The same options
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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.
|