@abinnovision/payloadcms-mcpx 2.0.0-beta.12 → 2.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +25 -6
- package/dist/write/draft-guard.mjs +11 -9
- package/dist/write/publish-intent.mjs +16 -6
- package/package.json +1 -1
package/dist/define-tool.mjs
CHANGED
|
@@ -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 &&
|
|
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
|
package/dist/endpoint/server.mjs
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,
|
|
111
|
+
export { createMcpServer, runTool };
|
package/dist/endpoint/upload.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { claimGrant,
|
|
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 {
|
|
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 =
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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(
|
|
46
|
-
const
|
|
47
|
-
const exp = now +
|
|
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: `${
|
|
57
|
+
key: `${args.prefix}${hashApiKey(payload.secret, id)}`,
|
|
52
58
|
data: {
|
|
53
|
-
...
|
|
59
|
+
...args.data,
|
|
54
60
|
exp
|
|
55
61
|
}
|
|
56
62
|
},
|
|
57
63
|
req: NO_REQ
|
|
58
64
|
});
|
|
59
65
|
return {
|
|
60
|
-
|
|
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,
|
|
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 ${
|
|
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) => !
|
|
46
|
-
if (unknownKey !== void 0) fail(`${kind} "${slug}" has the unknown option "${unknownKey}". The options are ${
|
|
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
|
|
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,
|
|
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);
|