@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.
- package/README.md +32 -20
- package/dist/admin/capability-toggles.mjs +28 -8
- 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 +2 -0
- package/dist/api-keys/capability-matrix.mjs +12 -0
- package/dist/api-keys/collection.mjs +3 -3
- package/dist/api-keys/confirmation-view.mjs +6 -0
- package/dist/api-keys/fields.mjs +18 -1
- package/dist/builtin-tool-names.mjs +3 -1
- package/dist/capabilities.mjs +6 -2
- 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 +28 -5
- 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/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/types.d.mts +129 -18
- 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
|
@@ -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 };
|
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 {
|
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
|
|
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.
|
package/dist/upload/file.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { grantKvSlug, issueGrant } from "../grants/grant.mjs";
|
|
1
2
|
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
3
|
import { SchemaError } from "../schema/errors.mjs";
|
|
3
4
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
4
5
|
import "../schema/index.mjs";
|
|
5
|
-
import { issueGrant, uploadKvSlug } from "./grant.mjs";
|
|
6
6
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
7
|
+
import { z } from "zod";
|
|
7
8
|
import { APIError } from "payload";
|
|
8
9
|
import { hasDraftValidationEnabled, hasDraftsEnabled, validateMimeType } from "payload/shared";
|
|
9
|
-
import { z } from "zod";
|
|
10
10
|
//#region src/upload/file.ts
|
|
11
11
|
const MAX_BYTES = 26214400;
|
|
12
12
|
const FILENAME = /^[^/\\\p{Cc}]+$/u;
|
|
@@ -121,7 +121,7 @@ const handoffUrl = (req, path) => {
|
|
|
121
121
|
return url.toString();
|
|
122
122
|
};
|
|
123
123
|
const grantContext = (req) => {
|
|
124
|
-
const slug =
|
|
124
|
+
const slug = grantKvSlug(req.payload.config);
|
|
125
125
|
const apiKeyId = req.context.mcpx?.apiKeyId;
|
|
126
126
|
if (slug === void 0 || apiKeyId === void 0) throw new Error("A handoff was offered without a database KV or key.");
|
|
127
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,
|
|
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
|
|
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"] =
|
|
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
|
|
33
|
-
return scrubWriteArgs(args,
|
|
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
|
-
|
|
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;
|
|
@@ -2,7 +2,13 @@ import { isPlainObject } from "../guards.mjs";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
//#region src/write/publish-intent.ts
|
|
4
4
|
const PUBLISH_INTENT = "__mcpxPublishIntent";
|
|
5
|
+
const TRASH_INTENT = "__mcpxTrashIntent";
|
|
5
6
|
const TOKEN = randomUUID();
|
|
7
|
+
const take = (data, key) => {
|
|
8
|
+
if (!isPlainObject(data) || data[key] !== TOKEN) return false;
|
|
9
|
+
delete data[key];
|
|
10
|
+
return true;
|
|
11
|
+
};
|
|
6
12
|
const withPublishIntent = (data) => ({
|
|
7
13
|
...data,
|
|
8
14
|
[PUBLISH_INTENT]: TOKEN
|
|
@@ -10,10 +16,14 @@ const withPublishIntent = (data) => ({
|
|
|
10
16
|
const hasPublishIntent = (data) => isPlainObject(data) && data[PUBLISH_INTENT] === TOKEN;
|
|
11
17
|
/**
|
|
12
18
|
* Called by the last hook that needs the marker, which removes it.
|
|
13
|
-
*/ const takePublishIntent = (data) =>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
};
|
|
19
|
+
*/ const takePublishIntent = (data) => take(data, PUBLISH_INTENT);
|
|
20
|
+
const withTrashIntent = (data) => ({
|
|
21
|
+
...data,
|
|
22
|
+
[TRASH_INTENT]: TOKEN
|
|
23
|
+
});
|
|
24
|
+
const hasTrashIntent = (data) => isPlainObject(data) && data[TRASH_INTENT] === TOKEN;
|
|
25
|
+
/**
|
|
26
|
+
* The counterpart of {@link takePublishIntent}.
|
|
27
|
+
*/ const takeTrashIntent = (data) => take(data, TRASH_INTENT);
|
|
18
28
|
//#endregion
|
|
19
|
-
export { hasPublishIntent, takePublishIntent, withPublishIntent };
|
|
29
|
+
export { hasPublishIntent, hasTrashIntent, takePublishIntent, takeTrashIntent, withPublishIntent, withTrashIntent };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@abinnovision/payloadcms-mcpx",
|
|
4
|
-
"version": "2.0.0-beta.
|
|
4
|
+
"version": "2.0.0-beta.14",
|
|
5
5
|
"description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"payload",
|