@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/api-keys/fields.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CAPABILITIES_FIELD } from "../capabilities.mjs";
|
|
2
|
-
import { CAPABILITIES_DESCRIPTION,
|
|
2
|
+
import { CAPABILITIES_DESCRIPTION, STORED_OPERATIONS, createCapabilityMatrix } from "./capability-matrix.mjs";
|
|
3
3
|
//#region src/api-keys/fields.ts
|
|
4
4
|
const encryptKey = ({ req, value }) => typeof value === "string" ? req.payload.encrypt(value) : value;
|
|
5
5
|
const decryptKey = ({ req, value }) => {
|
|
@@ -79,6 +79,23 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
79
79
|
}
|
|
80
80
|
];
|
|
81
81
|
/**
|
|
82
|
+
* The pending calls of the key, above everything else on its edit view, where
|
|
83
|
+
* a collection exposes `delete`. The component renders nothing while none is
|
|
84
|
+
* pending and never touches the form, so deciding does not mark the key
|
|
85
|
+
* modified.
|
|
86
|
+
*/ const createConfirmationFields = (options) => options.confirmations ? [{
|
|
87
|
+
name: "confirmations",
|
|
88
|
+
type: "ui",
|
|
89
|
+
admin: {
|
|
90
|
+
disableListColumn: true,
|
|
91
|
+
components: { Field: {
|
|
92
|
+
path: "@abinnovision/payloadcms-mcpx/admin",
|
|
93
|
+
exportName: "McpxConfirmations",
|
|
94
|
+
clientProps: { endpointPath: options.endpointPath }
|
|
95
|
+
} }
|
|
96
|
+
}
|
|
97
|
+
}] : [];
|
|
98
|
+
/**
|
|
82
99
|
* Wraps the key fields and the setup guide in unnamed tabs, so wide snippets
|
|
83
100
|
* get the full form width. Named tabs would nest the data and move
|
|
84
101
|
* `capabilities` off the document root, which capability resolution reads.
|
|
@@ -124,7 +141,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
124
141
|
name: row.fieldName,
|
|
125
142
|
type: "group",
|
|
126
143
|
label: row.label,
|
|
127
|
-
fields:
|
|
144
|
+
fields: STORED_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => checkbox(operation.id, operation.description))
|
|
128
145
|
}));
|
|
129
146
|
const collectionGroups = entityGroups(matrix.collections);
|
|
130
147
|
const globalGroups = entityGroups(matrix.globals);
|
|
@@ -165,4 +182,4 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
165
182
|
}];
|
|
166
183
|
};
|
|
167
184
|
//#endregion
|
|
168
|
-
export { createCapabilityFields, createKeyFields, withSetupGuideTab };
|
|
185
|
+
export { createCapabilityFields, createConfirmationFields, createKeyFields, withSetupGuideTab };
|
package/dist/capabilities.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { isPlainObject } from "./guards.mjs";
|
|
|
17
17
|
* Without drafts there is no draft stage, so every write is live.
|
|
18
18
|
*/ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
|
|
19
19
|
const flag = (group, name) => isPlainObject(group) && group[name] === true;
|
|
20
|
-
const
|
|
20
|
+
const writeFlag = (entity, group) => canWrite(entity) && entity.read && flag(group, "read") && flag(group, "write");
|
|
21
|
+
const publishFlag = (entity, group) => canPublish(entity) && writeFlag(entity, group) && flag(group, "publish");
|
|
21
22
|
/**
|
|
22
23
|
* Capabilities in force for a key: the plugin config decides what can exist,
|
|
23
24
|
* the key's checkboxes decide what does. A missing checkbox is `false`, so keys
|
|
@@ -28,10 +29,13 @@ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write"
|
|
|
28
29
|
const resolved = {};
|
|
29
30
|
for (const entity of entities) {
|
|
30
31
|
const group = isPlainObject(namespaceGroup) ? namespaceGroup[entity.fieldName] : void 0;
|
|
32
|
+
const deleting = entity.delete && flag(group, "read") && flag(group, "delete");
|
|
31
33
|
resolved[entity.slug] = {
|
|
32
34
|
read: entity.read && flag(group, "read"),
|
|
33
|
-
write:
|
|
34
|
-
publish: publishFlag(entity, group)
|
|
35
|
+
write: writeFlag(entity, group),
|
|
36
|
+
publish: publishFlag(entity, group),
|
|
37
|
+
delete: deleting,
|
|
38
|
+
deleteUnattended: deleting && entity.deleteUnattended && flag(group, "deleteUnattended")
|
|
35
39
|
};
|
|
36
40
|
}
|
|
37
41
|
return resolved;
|
|
@@ -53,7 +57,8 @@ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write"
|
|
|
53
57
|
return {
|
|
54
58
|
readable: slugsWith("read"),
|
|
55
59
|
writable: slugsWith("write"),
|
|
56
|
-
publishable: slugsWith("publish")
|
|
60
|
+
publishable: slugsWith("publish"),
|
|
61
|
+
deletable: slugsWith("delete")
|
|
57
62
|
};
|
|
58
63
|
};
|
|
59
64
|
//#endregion
|
package/dist/define-tool.d.mts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import { McpxAnyTool, McpxTool, McpxToolScope } from "./types.mjs";
|
|
1
|
+
import { McpxAnyTool, McpxTool, McpxToolConfirm, McpxToolDefinition, McpxToolScope } from "./types.mjs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
//#region src/define-tool.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* Fixed shape; arguments inferred from it.
|
|
6
6
|
*/
|
|
7
|
-
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool:
|
|
7
|
+
export declare function defineMcpxTool<Shape extends z.ZodRawShape, Confirm extends McpxToolConfirm | undefined = undefined>(tool: McpxToolDefinition<Shape, z.infer<z.ZodObject<Shape>>, Confirm> & {
|
|
8
8
|
inputSchema?: Shape;
|
|
9
9
|
}): McpxTool<Shape>;
|
|
10
10
|
/**
|
|
11
11
|
* Per-request shape returned as an object literal; arguments inferred from it.
|
|
12
12
|
*/
|
|
13
|
-
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool:
|
|
13
|
+
export declare function defineMcpxTool<Shape extends z.ZodRawShape, Confirm extends McpxToolConfirm | undefined = undefined>(tool: McpxToolDefinition<Shape, z.infer<z.ZodObject<Shape>>, Confirm> & {
|
|
14
14
|
inputSchema: (scope: McpxToolScope) => Shape;
|
|
15
15
|
}): McpxAnyTool;
|
|
16
16
|
/**
|
|
17
17
|
* Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
|
|
18
18
|
* builtins do. Nothing to infer from, so state the arguments instead.
|
|
19
19
|
*/
|
|
20
|
-
export declare function defineMcpxTool<Args>(tool:
|
|
20
|
+
export declare function defineMcpxTool<Args, Confirm extends McpxToolConfirm | undefined = undefined>(tool: McpxToolDefinition<z.ZodRawShape, Args, Confirm> & {
|
|
21
21
|
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
22
22
|
}): McpxAnyTool;
|
|
23
23
|
//#endregion
|
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,26 +43,42 @@ 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
|
-
const write = flag("write") ??
|
|
67
|
+
const write = flag("write") ?? read;
|
|
50
68
|
const publish = flag("publish");
|
|
51
69
|
const hasDrafts = hasDraftsEnabled(config);
|
|
52
70
|
if (publish === false && write && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so every write goes live. Set write: false or remove publish: false.`);
|
|
53
71
|
if (publish === true && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so there is nothing to publish. Enable versions.drafts or remove publish: true.`);
|
|
72
|
+
if (!read && (flag("write") === true || publish === true)) fail(`${kind} "${slug}" has read: false but write or publish on. Writing and publishing need read.`);
|
|
54
73
|
if (publish === true && !write) fail(`${kind} "${slug}" has publish: true but write: false. Publishing follows write.`);
|
|
74
|
+
const deleting = normalizeDelete(kind, config, settings["delete"]);
|
|
75
|
+
if (!read && deleting.delete) fail(`${kind} "${slug}" has read: false but delete on. Deleting needs read.`);
|
|
55
76
|
return {
|
|
56
77
|
read,
|
|
57
78
|
write: write ? publish === false ? "draft" : "live" : false,
|
|
58
79
|
hasDrafts,
|
|
59
|
-
hasVersions: read && Boolean(config.versions)
|
|
80
|
+
hasVersions: read && Boolean(config.versions),
|
|
81
|
+
...deleting
|
|
60
82
|
};
|
|
61
83
|
};
|
|
62
84
|
const assertGlobalExposable = (global) => {
|
|
@@ -138,8 +160,11 @@ const normalizeLimits = (limits) => {
|
|
|
138
160
|
assertUserCollection(config, userCollection);
|
|
139
161
|
const tools = options.tools ?? [];
|
|
140
162
|
assertTools(tools);
|
|
163
|
+
const collections = normalizeCollections(config, options, apiKeysSlug);
|
|
164
|
+
const confirmations = collections.some((entity) => entity.delete);
|
|
165
|
+
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
166
|
return {
|
|
142
|
-
collections
|
|
167
|
+
collections,
|
|
143
168
|
globals: normalizeGlobals(config, options),
|
|
144
169
|
userCollection,
|
|
145
170
|
apiKeysSlug,
|
|
@@ -147,6 +172,7 @@ const normalizeLimits = (limits) => {
|
|
|
147
172
|
setupGuide: options.apiKeys?.setupGuide ?? true,
|
|
148
173
|
limits: normalizeLimits(options.limits),
|
|
149
174
|
tools,
|
|
175
|
+
confirmations,
|
|
150
176
|
auth: options.auth,
|
|
151
177
|
serverInfo: {
|
|
152
178
|
name: options.serverInfo?.name ?? "payloadcms-mcpx",
|