@abinnovision/payloadcms-mcpx 2.0.0-beta.8 → 2.0.0-beta.9
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/dist/admin/capability-matrix.d.mts +12 -4
- package/dist/admin/setup-guide.d.mts +3 -1
- package/dist/api-keys/capability-matrix.d.mts +6 -2
- package/dist/api-keys/capability-matrix.mjs +3 -1
- package/dist/api-keys/collection.mjs +2 -1
- package/dist/api-keys/fields.mjs +21 -0
- package/dist/auth/resolve.mjs +23 -3
- package/dist/capabilities.mjs +9 -3
- package/dist/define-tool.d.mts +6 -2
- package/dist/endpoint/server.mjs +9 -3
- package/dist/guards.mjs +3 -1
- package/dist/i18n.mjs +3 -1
- package/dist/options.mjs +6 -2
- package/dist/schema/lexical.mjs +6 -2
- package/dist/schema/path.mjs +9 -3
- package/dist/schema/shape.mjs +1 -1
- package/dist/schema/walk.mjs +11 -3
- package/dist/tools/create-document.mjs +1 -1
- package/dist/tools/document.mjs +43 -3
- package/dist/tools/patch-document.mjs +4 -2
- package/dist/tools/publish-document.mjs +14 -9
- package/dist/tools/query-paths.mjs +3 -1
- package/dist/tools/shared.mjs +14 -5
- package/dist/tools/validate-document.mjs +5 -4
- package/dist/tools/versions.mjs +13 -4
- package/dist/types.d.mts +79 -26
- package/dist/write/draft-guard.mjs +12 -10
- package/dist/write/publish-intent.mjs +3 -1
- package/dist/write/row-ids.mjs +3 -1
- package/package.json +2 -1
|
@@ -2,18 +2,26 @@ import { CapabilityMatrix } from "../api-keys/capability-matrix.mjs";
|
|
|
2
2
|
import React from "react";
|
|
3
3
|
//#region src/admin/capability-matrix.d.ts
|
|
4
4
|
interface McpxCapabilityMatrixProps {
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Built from the plugin options at config time.
|
|
7
|
+
*/
|
|
6
8
|
matrix: CapabilityMatrix;
|
|
7
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* The group's own config, supplied by Payload. Carries the description.
|
|
11
|
+
*/
|
|
8
12
|
field?: {
|
|
9
13
|
admin?: {
|
|
10
14
|
description?: unknown;
|
|
11
15
|
};
|
|
12
16
|
};
|
|
13
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The `capabilities` group's own path, supplied by Payload.
|
|
19
|
+
*/
|
|
14
20
|
path?: string;
|
|
15
21
|
readOnly?: boolean;
|
|
16
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Whether the key form is split into tabs, from the plugin options.
|
|
24
|
+
*/
|
|
17
25
|
withinTab?: boolean;
|
|
18
26
|
}
|
|
19
27
|
/**
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
//#region src/admin/setup-guide.d.ts
|
|
3
3
|
interface McpxSetupGuideProps {
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Endpoint path below the API route, from the plugin options.
|
|
6
|
+
*/
|
|
5
7
|
endpointPath: string;
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
@@ -7,12 +7,16 @@ import "../options.mjs";
|
|
|
7
7
|
*/
|
|
8
8
|
export interface CapabilityRow {
|
|
9
9
|
fieldName: string;
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The slug, which is what MCP clients send and what refusals name.
|
|
12
|
+
*/
|
|
11
13
|
label: string;
|
|
12
14
|
read: boolean;
|
|
13
15
|
write: boolean;
|
|
14
16
|
publish: boolean;
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Said only where a row departs from what its column header promises.
|
|
19
|
+
*/
|
|
16
20
|
hint?: string;
|
|
17
21
|
}
|
|
18
22
|
interface CapabilityTool {
|
|
@@ -53,6 +53,8 @@ const toRow = (entity) => ({
|
|
|
53
53
|
});
|
|
54
54
|
const cellPath = (basePath, namespace, fieldName, operation) => `${basePath}.${namespace}.${fieldName}.${operation}`;
|
|
55
55
|
const toolPath = (basePath, name) => `${basePath}.tools.${name}`;
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Every path the matrix can address, in a stable order.
|
|
58
|
+
*/ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) => CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
|
|
57
59
|
//#endregion
|
|
58
60
|
export { CAPABILITIES_DESCRIPTION, CAPABILITY_OPERATIONS, capabilityPaths, cellPath, createCapabilityMatrix, toolPath };
|
|
@@ -5,7 +5,8 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
|
|
|
5
5
|
* Generates the key on create and keeps the lookup index in step with it.
|
|
6
6
|
* Runs after field `beforeValidate` fallbacks, so on update `data.apiKey` is the
|
|
7
7
|
* decrypted plaintext of the stored key.
|
|
8
|
-
*/ const keyBeforeChange = ({ data, operation, req }) => {
|
|
8
|
+
*/ const keyBeforeChange = ({ context, data, operation, req }) => {
|
|
9
|
+
if (context["mcpxTouch"]) return data;
|
|
9
10
|
if (operation === "create" && typeof data["apiKey"] !== "string") data["apiKey"] = generateApiKey();
|
|
10
11
|
if (typeof data["apiKey"] === "string") data["apiKeyIndex"] = hashApiKey(req.payload.secret, data["apiKey"]);
|
|
11
12
|
return data;
|
package/dist/api-keys/fields.mjs
CHANGED
|
@@ -34,6 +34,27 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
34
34
|
defaultValue: true,
|
|
35
35
|
admin: { description: "Disabled keys are refused without revoking them." }
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
name: "expiresAt",
|
|
39
|
+
type: "date",
|
|
40
|
+
admin: {
|
|
41
|
+
date: { pickerAppearance: "dayAndTime" },
|
|
42
|
+
description: "Refused from this time on. Leave empty for no expiry."
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "lastUsedAt",
|
|
47
|
+
type: "date",
|
|
48
|
+
access: {
|
|
49
|
+
create: () => false,
|
|
50
|
+
update: () => false
|
|
51
|
+
},
|
|
52
|
+
admin: {
|
|
53
|
+
readOnly: true,
|
|
54
|
+
date: { pickerAppearance: "dayAndTime" },
|
|
55
|
+
description: "Updated at most once an hour."
|
|
56
|
+
}
|
|
57
|
+
},
|
|
37
58
|
{
|
|
38
59
|
name: "apiKey",
|
|
39
60
|
type: "text",
|
package/dist/auth/resolve.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { isPlainObject } from "../guards.mjs";
|
|
|
2
2
|
import { hashApiKey } from "../api-keys/key.mjs";
|
|
3
3
|
//#region src/auth/resolve.ts
|
|
4
4
|
const BEARER = /^Bearer\s+(\S+)\s*$/i;
|
|
5
|
+
const LAST_USED_INTERVAL_MS = 36e5;
|
|
6
|
+
const toTime = (value) => typeof value === "string" || value instanceof Date ? new Date(value).getTime() : NaN;
|
|
5
7
|
const relationId = (value) => {
|
|
6
8
|
if (typeof value === "string" || typeof value === "number") return value;
|
|
7
9
|
if (typeof value === "object" && value !== null && "id" in value) return value.id;
|
|
@@ -39,12 +41,15 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
|
39
41
|
select: {
|
|
40
42
|
enabled: true,
|
|
41
43
|
user: true,
|
|
42
|
-
capabilities: true
|
|
44
|
+
capabilities: true,
|
|
45
|
+
expiresAt: true,
|
|
46
|
+
lastUsedAt: true
|
|
43
47
|
}
|
|
44
48
|
});
|
|
45
49
|
const keyDoc = docs[0];
|
|
46
50
|
const userId = relationId(keyDoc?.user);
|
|
47
51
|
if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
|
|
52
|
+
if (toTime(keyDoc.expiresAt) <= Date.now()) return null;
|
|
48
53
|
const userCollection = payload.collections[options.userCollection];
|
|
49
54
|
const lookup = {
|
|
50
55
|
collection: options.userCollection,
|
|
@@ -61,9 +66,24 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
|
61
66
|
showHiddenFields: true,
|
|
62
67
|
select: { lockUntil: true }
|
|
63
68
|
})]);
|
|
64
|
-
const
|
|
65
|
-
const lockUntil = typeof storedLock === "string" || storedLock instanceof Date ? new Date(storedLock).getTime() : NaN;
|
|
69
|
+
const lockUntil = toTime(lock?.["lockUntil"]);
|
|
66
70
|
if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
|
|
71
|
+
const lastUsed = toTime(keyDoc.lastUsedAt);
|
|
72
|
+
if (Number.isNaN(lastUsed) || Date.now() - lastUsed > LAST_USED_INTERVAL_MS) try {
|
|
73
|
+
await payload.update({
|
|
74
|
+
collection: options.apiKeysSlug,
|
|
75
|
+
id: keyDoc.id,
|
|
76
|
+
data: { lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
77
|
+
overrideAccess: true,
|
|
78
|
+
depth: 0,
|
|
79
|
+
context: { mcpxTouch: true }
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
payload.logger.warn({
|
|
83
|
+
err: error,
|
|
84
|
+
msg: "Could not record the last use of an MCP API key"
|
|
85
|
+
});
|
|
86
|
+
}
|
|
67
87
|
return {
|
|
68
88
|
user: {
|
|
69
89
|
...user,
|
package/dist/capabilities.mjs
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { isPlainObject } from "./guards.mjs";
|
|
2
2
|
//#region src/capabilities.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Group field holding the capability checkboxes on an API key document.
|
|
5
|
+
*/ const CAPABILITIES_FIELD = "capabilities";
|
|
6
|
+
/**
|
|
7
|
+
* Covers draft and live writes; {@link isLiveWrite} separates them.
|
|
8
|
+
*/ const canWrite = (entity) => entity.write !== false;
|
|
9
|
+
/**
|
|
10
|
+
* The config lets MCP change live content and a draft exists to promote.
|
|
11
|
+
*/ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
|
|
6
12
|
/**
|
|
7
13
|
* An upload document is a file, and no tool here carries one. Its own fields
|
|
8
14
|
* stay patchable; the first version is made in the admin panel.
|
package/dist/define-tool.d.mts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { McpxAnyTool, McpxTool, McpxToolScope } from "./types.mjs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
//#region src/define-tool.d.ts
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Fixed shape; arguments inferred from it.
|
|
6
|
+
*/
|
|
5
7
|
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
6
8
|
inputSchema?: Shape;
|
|
7
9
|
}): McpxTool<Shape>;
|
|
8
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Per-request shape returned as an object literal; arguments inferred from it.
|
|
12
|
+
*/
|
|
9
13
|
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
10
14
|
inputSchema: (scope: McpxToolScope) => Shape;
|
|
11
15
|
}): McpxAnyTool;
|
package/dist/endpoint/server.mjs
CHANGED
|
@@ -4,9 +4,13 @@ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
//#region src/endpoint/server.ts
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Strict, so an unknown argument is rejected by name instead of stripped.
|
|
9
|
+
*/ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
|
|
8
10
|
const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* A tool that does not decide for itself is gated by its own checkbox.
|
|
13
|
+
*/ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
|
|
10
14
|
const WORKFLOW = [
|
|
11
15
|
{
|
|
12
16
|
tools: ["listCapabilities"],
|
|
@@ -36,7 +40,9 @@ const WORKFLOW = [
|
|
|
36
40
|
const PATHS = `describeSchema takes and returns schema paths. Every other "path", and "from" in a patch operation, is a pointer into a document, with a 0-based index where a schema path has "*" for an array element or a block slug: "/layout/sections/sectionWrapper/identifier" is "/layout/sections/0/identifier". In rich text a pointer enters the rich text state: "/content/block/callout/tone" is "/content/root/children/3/fields/tone". getDocument "outline" gives the index.`;
|
|
37
41
|
const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
|
|
38
42
|
const DRAFTS_ONLY = "Every write is saved as a draft.";
|
|
39
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The workflow for the tools this key has, and what its writes reach.
|
|
45
|
+
*/ const serverInstructions = (scope) => {
|
|
40
46
|
const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
|
|
41
47
|
const steps = WORKFLOW.flatMap(({ tools, does }) => {
|
|
42
48
|
const present = tools.filter((name) => enabled.has(name));
|
package/dist/guards.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
//#region src/guards.ts
|
|
2
2
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* An own property only, so a client-chosen key never reads the prototype.
|
|
5
|
+
*/ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
|
|
4
6
|
//#endregion
|
|
5
7
|
export { isPlainObject, ownValue };
|
package/dist/i18n.mjs
CHANGED
|
@@ -21,7 +21,9 @@ const pick = (record, language) => {
|
|
|
21
21
|
return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
|
|
22
22
|
};
|
|
23
23
|
const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* For callers with no request. Both languages miss, so the first entry wins.
|
|
26
|
+
*/ const translateAny = translatorFor({
|
|
25
27
|
fallbackLanguage: "",
|
|
26
28
|
language: ""
|
|
27
29
|
});
|
package/dist/options.mjs
CHANGED
|
@@ -12,7 +12,9 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
|
|
|
12
12
|
const fail = (message) => {
|
|
13
13
|
throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
|
|
14
14
|
};
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The same transform the stock MCP plugin uses to derive field names.
|
|
17
|
+
*/ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
|
|
16
18
|
const assertExposable = (collection, apiKeysSlug) => {
|
|
17
19
|
const { slug } = collection;
|
|
18
20
|
if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
|
|
@@ -127,7 +129,9 @@ const normalizeLimits = (limits) => {
|
|
|
127
129
|
maxDepth
|
|
128
130
|
};
|
|
129
131
|
};
|
|
130
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Every problem is an `InvalidConfiguration`, so it fails at startup.
|
|
134
|
+
*/ const normalizeOptions = (config, options) => {
|
|
131
135
|
const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
|
|
132
136
|
const userCollection = options.userCollection ?? config.admin?.user ?? "users";
|
|
133
137
|
if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
|
package/dist/schema/lexical.mjs
CHANGED
|
@@ -11,7 +11,9 @@ const LEXICAL_CORE_NODES = [
|
|
|
11
11
|
const OPAQUE_NODE_TYPES = /* @__PURE__ */ new Set(["upload"]);
|
|
12
12
|
const subSchemaCache = /* @__PURE__ */ new WeakMap();
|
|
13
13
|
const featuresOf = (field) => field.editor?.editorConfig?.features;
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Editors other than Lexical report only the core nodes.
|
|
16
|
+
*/ const allowedNodeTypes = (field) => {
|
|
15
17
|
const registered = (featuresOf(field)?.nodes ?? []).flatMap((entry) => {
|
|
16
18
|
const type = entry.node?.getType?.();
|
|
17
19
|
return type ? [type] : [];
|
|
@@ -41,7 +43,9 @@ const lexicalSubSchema = (field, nodeType) => {
|
|
|
41
43
|
if (!cached.has(nodeType)) cached.set(nodeType, resolveSubSchema(field, nodeType));
|
|
42
44
|
return cached.get(nodeType) ?? void 0;
|
|
43
45
|
};
|
|
44
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* In the order their features registered them.
|
|
48
|
+
*/ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
|
|
45
49
|
const KINDS = {
|
|
46
50
|
array: {
|
|
47
51
|
accepts: (value) => Array.isArray(value),
|
package/dist/schema/path.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
//#region src/schema/path.ts
|
|
2
2
|
const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* No segments is the root pointer, `""`.
|
|
5
|
+
*/ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
|
|
6
|
+
/**
|
|
7
|
+
* Unescapes `~1` and `~0`. The root pointer yields no segments.
|
|
8
|
+
*/ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
5
9
|
const PROTOTYPE_SEGMENTS = /* @__PURE__ */ new Set([
|
|
6
10
|
"__proto__",
|
|
7
11
|
"constructor",
|
|
@@ -12,7 +16,9 @@ const PROTOTYPE_SEGMENTS = /* @__PURE__ */ new Set([
|
|
|
12
16
|
* intermediate tokens but writes them as a final key, so a pointer containing
|
|
13
17
|
* one is refused before anything resolves it.
|
|
14
18
|
*/ const prototypeSegmentProblem = (pointer) => splitPath(pointer).some((segment) => PROTOTYPE_SEGMENTS.has(segment)) ? `"${pointer}" contains a segment named __proto__, constructor or prototype, which no field or node property uses.` : void 0;
|
|
15
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* `-` included, since RFC 6901 reads it as the position after the last.
|
|
21
|
+
*/ const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
|
|
16
22
|
/**
|
|
17
23
|
* A path Payload reports on a validation error (`layout.0.title`) as a JSON
|
|
18
24
|
* Pointer, so everything handed back addresses documents the same way. The
|
package/dist/schema/shape.mjs
CHANGED
|
@@ -220,7 +220,7 @@ const checkValue = (scope, value) => {
|
|
|
220
220
|
}, entry);
|
|
221
221
|
}
|
|
222
222
|
};
|
|
223
|
-
|
|
223
|
+
const validateWriteValue = (config, target, value) => {
|
|
224
224
|
const problems = [];
|
|
225
225
|
const scope = {
|
|
226
226
|
config,
|
package/dist/schema/walk.mjs
CHANGED
|
@@ -10,12 +10,17 @@ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
|
|
|
10
10
|
"id",
|
|
11
11
|
"updatedAt"
|
|
12
12
|
]);
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The keys a row carries beside its fields.
|
|
15
|
+
*/ const ROW_KEYS = /* @__PURE__ */ new Set([
|
|
14
16
|
"blockName",
|
|
15
17
|
"blockType",
|
|
16
18
|
"id"
|
|
17
19
|
]);
|
|
18
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* On a flattened field, whichever of `blockReferences` and `blocks` was
|
|
22
|
+
* declared.
|
|
23
|
+
*/ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
|
|
19
24
|
/**
|
|
20
25
|
* An inlined definition wins over the registry (`config.blocks`). A block's own
|
|
21
26
|
* fields are identical wherever it appears, but the blocks its children accept
|
|
@@ -101,7 +106,10 @@ const isInformative = (descriptor) => descriptor.description !== void 0 || descr
|
|
|
101
106
|
* against a document needs. Containers are left out, since only
|
|
102
107
|
* {@link nodeDescriber} reports one.
|
|
103
108
|
*/ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
|
|
104
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* The addressable descriptors under `prefix`, with their paths split relative
|
|
111
|
+
* to it.
|
|
112
|
+
*/ const descriptorsUnder = (fields, prefix) => describeAddressableFields(fields).flatMap((descriptor) => {
|
|
105
113
|
const parts = splitPath(descriptor.path);
|
|
106
114
|
return prefix.every((part, offset) => part === parts[offset]) ? [{
|
|
107
115
|
descriptor,
|
|
@@ -3,8 +3,8 @@ import { errorResult, jsonResult } from "../result.mjs";
|
|
|
3
3
|
import { validateWriteValue } from "../schema/shape.mjs";
|
|
4
4
|
import "../schema/index.mjs";
|
|
5
5
|
import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
|
|
6
|
-
import { readDraft, resolveEntity } from "./document.mjs";
|
|
7
6
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
7
|
+
import { readDraft, resolveEntity } from "./document.mjs";
|
|
8
8
|
import { stripRowIds } from "../write/row-ids.mjs";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
//#region src/tools/create-document.ts
|
package/dist/tools/document.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errorResult } from "../result.mjs";
|
|
2
2
|
import { slugsFor } from "./shared.mjs";
|
|
3
|
+
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
3
4
|
import { APIError, Forbidden, NotFound } from "payload";
|
|
4
5
|
//#region src/tools/document.ts
|
|
5
6
|
/**
|
|
@@ -45,9 +46,13 @@ import { APIError, Forbidden, NotFound } from "payload";
|
|
|
45
46
|
id: args.id
|
|
46
47
|
};
|
|
47
48
|
};
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* What a response names its subject by: a document's id, or a global's slug.
|
|
51
|
+
*/ const identityOf = (target, id) => target.kind === "collection" ? { id } : { global: target.slug };
|
|
49
52
|
const sameInstant = (left, right) => (typeof left === "string" || left instanceof Date) && new Date(left).getTime() === new Date(right).getTime();
|
|
50
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Refuses a write when the document changed since the client read it.
|
|
55
|
+
*/ const staleReadResult = (doc, expectedUpdatedAt, message) => expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], expectedUpdatedAt) ? errorResult(message, { updatedAt: doc["updatedAt"] }) : void 0;
|
|
51
56
|
/**
|
|
52
57
|
* Reads the current draft in a fixed locale with no fallback, which is the
|
|
53
58
|
* shape that may be written back or validated without mixing locales.
|
|
@@ -80,5 +85,40 @@ const sameInstant = (left, right) => (typeof left === "string" || left instanceo
|
|
|
80
85
|
slug: args.target.slug
|
|
81
86
|
});
|
|
82
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* {@link collectPublishBlockers} over the draft of each locale, each blocker
|
|
90
|
+
* tagged with its locale. Locales run in turn because hooks share `req`, and
|
|
91
|
+
* the request's locale and fallback locale are restored afterwards.
|
|
92
|
+
*/ const collectLocaleBlockers = async (scope, target, locales) => {
|
|
93
|
+
const { req } = scope;
|
|
94
|
+
const { fallbackLocale, locale: requestLocale } = req;
|
|
95
|
+
const blockers = [];
|
|
96
|
+
let unavailable = false;
|
|
97
|
+
for (const locale of locales) try {
|
|
98
|
+
const doc = await readDraft(scope, {
|
|
99
|
+
target,
|
|
100
|
+
locale,
|
|
101
|
+
privileged: true
|
|
102
|
+
});
|
|
103
|
+
const result = await collectPublishBlockers(req, {
|
|
104
|
+
doc,
|
|
105
|
+
entity: target
|
|
106
|
+
});
|
|
107
|
+
unavailable ||= result.unavailable === true;
|
|
108
|
+
blockers.push(...result.blockers.map((entry) => ({
|
|
109
|
+
...entry,
|
|
110
|
+
locale
|
|
111
|
+
})));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Could not read the ${target.slug} draft in ${locale}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
114
|
+
unavailable = true;
|
|
115
|
+
}
|
|
116
|
+
if (requestLocale !== void 0) req.locale = requestLocale;
|
|
117
|
+
if (fallbackLocale !== void 0) req.fallbackLocale = fallbackLocale;
|
|
118
|
+
return {
|
|
119
|
+
blockers,
|
|
120
|
+
...unavailable ? { unavailable: true } : {}
|
|
121
|
+
};
|
|
122
|
+
};
|
|
83
123
|
//#endregion
|
|
84
|
-
export { identityOf, readDraft, resolveDocument, resolveEntity, staleReadResult };
|
|
124
|
+
export { collectLocaleBlockers, identityOf, readDraft, resolveDocument, resolveEntity, staleReadResult };
|
|
@@ -4,8 +4,8 @@ import { errorResult, jsonResult } from "../result.mjs";
|
|
|
4
4
|
import { JSON_POINTER_PATTERN } from "../schema/path.mjs";
|
|
5
5
|
import "../schema/index.mjs";
|
|
6
6
|
import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
|
|
7
|
-
import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
|
|
8
7
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
8
|
+
import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
|
|
9
9
|
import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
|
|
10
10
|
import { withTransaction } from "../write/transaction.mjs";
|
|
11
11
|
import { buildWriteData } from "../write/write-data.mjs";
|
|
@@ -24,7 +24,9 @@ In a rich text field a pointer continues into the rich text state: "/content/roo
|
|
|
24
24
|
The response carries "updatedAt" and, if any, "publishBlockers": what must be fixed before publishing. "publishBlockersUnavailable" means that check failed. The write stands either way. "notApplied" lists pointers whose value did not change or cannot be read back, for example where field access denies the update.`;
|
|
25
25
|
const POINTER = z.string().regex(JSON_POINTER_PATTERN);
|
|
26
26
|
const PATCHES_LIMIT = 500;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Discriminated on `op`, so an operation carries only its own members.
|
|
29
|
+
*/ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
|
|
28
30
|
z.strictObject({
|
|
29
31
|
op: z.literal("add"),
|
|
30
32
|
path: POINTER,
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { defineMcpxTool } from "../define-tool.mjs";
|
|
2
2
|
import { jsonResult } from "../result.mjs";
|
|
3
|
-
import { entityShape, idShape, localeOf } from "./shared.mjs";
|
|
4
|
-
import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
|
|
3
|
+
import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
|
|
4
|
+
import { collectLocaleBlockers, identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
|
|
5
5
|
import { withTransaction } from "../write/transaction.mjs";
|
|
6
6
|
import { withPublishIntent } from "../write/publish-intent.mjs";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
/**
|
|
9
|
-
* Publishes a draft. Available where the config exposes
|
|
10
|
-
*
|
|
11
|
-
* `publish` checkboxes.
|
|
9
|
+
* Publishes a draft. Available where the config exposes `publish` on an entity
|
|
10
|
+
* with drafts and the key has both the `write` and `publish` checkboxes.
|
|
12
11
|
*/ const publishDocument = defineMcpxTool({
|
|
13
12
|
name: "publishDocument",
|
|
14
|
-
description: `Makes the current draft of one document or global public
|
|
13
|
+
description: `Makes the current draft of one document or global public, in every locale or only in "locale". A scoped publish keeps the other locales of localized fields as last published, and a never-published document goes live in full. A draft with publish blockers is refused, so call validateDocument first. Payload checks only the published locale, so "otherLocaleBlockers" lists what other locales still lack. There is no unpublish.`,
|
|
15
14
|
annotations: {
|
|
16
15
|
destructiveHint: true,
|
|
17
16
|
openWorldHint: false
|
|
@@ -20,12 +19,14 @@ import { z } from "zod";
|
|
|
20
19
|
inputSchema: (scope) => ({
|
|
21
20
|
...entityShape(scope, "publish"),
|
|
22
21
|
...idShape(scope, "publish"),
|
|
22
|
+
...localeShape(scope, { required: false }),
|
|
23
23
|
expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
|
|
24
24
|
}),
|
|
25
25
|
handler: async ({ args, scope }) => {
|
|
26
26
|
const target = resolveDocument(scope, args, "publish");
|
|
27
27
|
const { payload } = scope.req;
|
|
28
|
-
const locale = localeOf(scope,
|
|
28
|
+
const locale = localeOf(scope, args.locale);
|
|
29
|
+
const { localization } = scope;
|
|
29
30
|
return await withTransaction(scope.req, async () => {
|
|
30
31
|
const doc = await readDraft(scope, {
|
|
31
32
|
target,
|
|
@@ -40,7 +41,8 @@ import { z } from "zod";
|
|
|
40
41
|
fallbackLocale: false,
|
|
41
42
|
overrideAccess: false,
|
|
42
43
|
req: scope.req,
|
|
43
|
-
...locale === void 0 ? {} : { locale }
|
|
44
|
+
...locale === void 0 ? {} : { locale },
|
|
45
|
+
...args.locale === void 0 ? {} : { publishSpecificLocale: args.locale }
|
|
44
46
|
};
|
|
45
47
|
if (target.kind === "collection") await payload.update({
|
|
46
48
|
...write,
|
|
@@ -56,10 +58,13 @@ import { z } from "zod";
|
|
|
56
58
|
locale,
|
|
57
59
|
privileged: true
|
|
58
60
|
});
|
|
61
|
+
const others = await collectLocaleBlockers(scope, target, localization?.locales.filter((entry) => entry !== locale) ?? []);
|
|
59
62
|
return jsonResult({
|
|
60
63
|
...identityOf(target, saved["id"]),
|
|
61
64
|
status: saved["_status"],
|
|
62
|
-
updatedAt: saved["updatedAt"]
|
|
65
|
+
updatedAt: saved["updatedAt"],
|
|
66
|
+
...others.blockers.length > 0 ? { otherLocaleBlockers: others.blockers } : {},
|
|
67
|
+
...others.unavailable ? { otherLocaleBlockersUnavailable: true } : {}
|
|
63
68
|
});
|
|
64
69
|
});
|
|
65
70
|
}
|
|
@@ -8,7 +8,9 @@ import { APIError, getLocalizedPaths } from "payload";
|
|
|
8
8
|
* them: `and` and `or` (any case) hold arrays of clauses, every other key is a
|
|
9
9
|
* path.
|
|
10
10
|
*/ const wherePaths = (where) => isPlainObject(where) ? Object.entries(where).flatMap(([key, clause]) => ["and", "or"].includes(key.toLowerCase()) && Array.isArray(clause) ? clause.flatMap(wherePaths) : [key]) : [];
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The paths of a `sort`: a field name, `-` for descending, comma separated.
|
|
13
|
+
*/ const sortPaths = (sort) => (sort ?? "").split(",").map((entry) => entry.trim().replace(/^-/, "")).filter(Boolean);
|
|
12
14
|
const normalise = (path) => path === "_id" ? "id" : path.replaceAll("__", ".");
|
|
13
15
|
const isRefusedPath = (scope, args, seen = /* @__PURE__ */ new Set()) => {
|
|
14
16
|
const key = `${args.collection}:${args.path}`;
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { z } from "zod";
|
|
|
5
5
|
* An out-of-scope slug fails schema validation before a handler runs, so a
|
|
6
6
|
* client only ever sees what its key may touch.
|
|
7
7
|
*/ const slugEnum = (slugs) => z.enum(slugs);
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Payload's id type follows the adapter, so both forms are handed on as read.
|
|
10
|
+
*/ const idSchema = z.union([z.string(), z.number()]);
|
|
9
11
|
const slugsOf = (scope, key) => ({
|
|
10
12
|
collections: scope.collections[key],
|
|
11
13
|
globals: scope.globals[key]
|
|
@@ -22,7 +24,9 @@ const slugsWhere = (scope, predicate, allowed) => {
|
|
|
22
24
|
globals: []
|
|
23
25
|
});
|
|
24
26
|
const widen = (branch) => branch;
|
|
25
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The one list the shape helpers and {@link resolveEntity} both read.
|
|
29
|
+
*/ const slugsFor = (scope, operation) => {
|
|
26
30
|
switch (operation) {
|
|
27
31
|
case "create": return {
|
|
28
32
|
collections: slugsWhere(scope, canCreate, {
|
|
@@ -62,7 +66,10 @@ const widen = (branch) => branch;
|
|
|
62
66
|
})
|
|
63
67
|
};
|
|
64
68
|
};
|
|
65
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Slugs reachable by `operation` whose writes go live, because they have no
|
|
71
|
+
* draft to write.
|
|
72
|
+
*/ const liveWriteSlugs = (scope, operation) => slugsWhere(scope, isLiveWrite, slugsFor(scope, operation));
|
|
66
73
|
/**
|
|
67
74
|
* Stated on the tools that write, since a client calling them must know before
|
|
68
75
|
* the call which writes are public.
|
|
@@ -72,7 +79,7 @@ const widen = (branch) => branch;
|
|
|
72
79
|
};
|
|
73
80
|
/**
|
|
74
81
|
* With no reachable global, `global` is left out and `collection` stays
|
|
75
|
-
* required, so a deployment without globals
|
|
82
|
+
* required, so a deployment without globals gets no extra argument. Only the
|
|
76
83
|
* mixed case makes either optional, and the handler enforces exclusivity there.
|
|
77
84
|
* `globalRule` states that rule on `global` for a tool without an `id`.
|
|
78
85
|
*/ const entityShape = (scope, operation, globalRule) => {
|
|
@@ -94,7 +101,9 @@ const widen = (branch) => branch;
|
|
|
94
101
|
if (globals.length === 0) return widen({ id: idSchema });
|
|
95
102
|
return widen({ id: idSchema.optional().describe("Required with \"collection\", omitted with \"global\".") });
|
|
96
103
|
};
|
|
97
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* For the reads that fall back to the default locale, unlike the writes.
|
|
106
|
+
*/ const READ_LOCALE_DESCRIPTION = "Default: the default locale. A value missing in this locale is shown from the default locale.";
|
|
98
107
|
const localeShape = (scope, options) => {
|
|
99
108
|
if (!scope.localization) return widen({});
|
|
100
109
|
const enumerated = z.enum(scope.localization.locales);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { defineMcpxTool } from "../define-tool.mjs";
|
|
2
2
|
import { jsonResult } from "../result.mjs";
|
|
3
3
|
import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
|
|
4
|
-
import { identityOf, readDraft, resolveDocument } from "./document.mjs";
|
|
5
4
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
5
|
+
import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from "./document.mjs";
|
|
6
6
|
/**
|
|
7
7
|
* Gated on write rather than read, because publish blockers only mean something
|
|
8
8
|
* to a caller who can act on them.
|
|
@@ -13,16 +13,17 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
|
13
13
|
* the traversal fires field hooks.
|
|
14
14
|
*/ const validateDocument = defineMcpxTool({
|
|
15
15
|
name: "validateDocument",
|
|
16
|
-
description: `Lists what still blocks publishing one document or global, without saving. Returns "publishBlockers", each with a pointer and a message. An empty list means it can be published, unless "publishBlockersUnavailable" is true: then the check itself failed. The check runs the field hooks a save runs.`,
|
|
16
|
+
description: `Lists what still blocks publishing one document or global, without saving. Checks one locale when "locale" is given, otherwise every locale. Returns "publishBlockers", each with a pointer and a message, and a "locale" when every locale was checked. An empty list means it can be published, unless "publishBlockersUnavailable" is true: then the check itself failed. The check runs the field hooks a save runs.`,
|
|
17
17
|
annotations: { openWorldHint: false },
|
|
18
18
|
isEnabled: (scope) => scope.collections.writable.length + scope.globals.writable.length > 0,
|
|
19
19
|
inputSchema: (scope) => ({
|
|
20
20
|
...entityShape(scope, "write"),
|
|
21
21
|
...idShape(scope, "write"),
|
|
22
|
-
...localeShape(scope, { required:
|
|
22
|
+
...localeShape(scope, { required: false })
|
|
23
23
|
}),
|
|
24
24
|
handler: async ({ args, scope }) => {
|
|
25
25
|
const target = resolveDocument(scope, args, "write");
|
|
26
|
+
const { localization } = scope;
|
|
26
27
|
const locale = localeOf(scope, args.locale);
|
|
27
28
|
await readDraft(scope, {
|
|
28
29
|
target,
|
|
@@ -33,7 +34,7 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
|
33
34
|
locale,
|
|
34
35
|
privileged: true
|
|
35
36
|
});
|
|
36
|
-
const validation = await collectPublishBlockers(scope.req, {
|
|
37
|
+
const validation = localization && args.locale === void 0 ? await collectLocaleBlockers(scope, target, localization.locales) : await collectPublishBlockers(scope.req, {
|
|
37
38
|
doc,
|
|
38
39
|
entity: target
|
|
39
40
|
});
|
package/dist/tools/versions.mjs
CHANGED
|
@@ -12,7 +12,10 @@ const NOISE = [
|
|
|
12
12
|
"_status"
|
|
13
13
|
];
|
|
14
14
|
const NEWEST_FIRST = ["-updatedAt", "-id"];
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The document or global as the key's user sees it, without `admin.hidden`
|
|
17
|
+
* fields.
|
|
18
|
+
*/ const readLive = async (scope, read, options) => {
|
|
16
19
|
const shared = {
|
|
17
20
|
depth: read.depth,
|
|
18
21
|
draft: options.draft,
|
|
@@ -44,7 +47,9 @@ const assertReadable = async (scope, read) => {
|
|
|
44
47
|
select: {}
|
|
45
48
|
});
|
|
46
49
|
};
|
|
47
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* `parent` comes back as a raw id or, populated, as the document itself.
|
|
52
|
+
*/ const isVersionOf = (version, id) => {
|
|
48
53
|
const parent = version["parent"];
|
|
49
54
|
const parentId = typeof parent === "object" && parent !== null ? parent["id"] : parent;
|
|
50
55
|
return (typeof parentId === "string" || typeof parentId === "number") && String(parentId) === String(id);
|
|
@@ -76,7 +81,9 @@ const assertReadable = async (scope, read) => {
|
|
|
76
81
|
if (read.target.kind === "collection" && !isVersionOf(version, read.target.id)) return null;
|
|
77
82
|
return withoutHidden(scope, read, version);
|
|
78
83
|
};
|
|
79
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* One page of a document's or global's history, newest first.
|
|
86
|
+
*/ const queryVersions = async (scope, read, options) => {
|
|
80
87
|
await assertReadable(scope, read);
|
|
81
88
|
const where = {
|
|
82
89
|
...read.target.kind === "collection" ? { parent: { equals: read.target.id } } : {},
|
|
@@ -100,7 +107,9 @@ const assertReadable = async (scope, read) => {
|
|
|
100
107
|
slug: read.target.slug
|
|
101
108
|
}));
|
|
102
109
|
};
|
|
103
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* The newest version whose status is published, or `null` if none is.
|
|
112
|
+
*/ const loadPublished = async (scope, read) => {
|
|
104
113
|
if (!hasDraftsEnabled(read.target.config)) return null;
|
|
105
114
|
const [newest] = (await queryVersions(scope, read, {
|
|
106
115
|
status: "published",
|
package/dist/types.d.mts
CHANGED
|
@@ -29,7 +29,9 @@ export type McpxWriteMode = "draft" | "live" | false;
|
|
|
29
29
|
* shorthand for `{}`, which exposes everything the entity supports.
|
|
30
30
|
*/
|
|
31
31
|
export interface McpxCollectionOptions {
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`.
|
|
34
|
+
*/
|
|
33
35
|
read?: boolean;
|
|
34
36
|
/**
|
|
35
37
|
* Expose `patchDocument`, `validateDocument` and, unless this is an upload
|
|
@@ -44,10 +46,15 @@ export interface McpxCollectionOptions {
|
|
|
44
46
|
*/
|
|
45
47
|
publish?: boolean;
|
|
46
48
|
}
|
|
47
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The same options. A singleton, so neither `findDocuments` nor
|
|
51
|
+
* `createDocument` reaches one.
|
|
52
|
+
*/
|
|
48
53
|
export type McpxGlobalOptions = McpxCollectionOptions;
|
|
49
54
|
export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
50
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* What the config exposes, before an API key's checkboxes narrow it.
|
|
57
|
+
*/
|
|
51
58
|
export interface McpxExposedEntity {
|
|
52
59
|
slug: string;
|
|
53
60
|
read: boolean;
|
|
@@ -58,24 +65,34 @@ export interface McpxExposedEntity {
|
|
|
58
65
|
* readable. Says nothing about drafts; see `hasDrafts`.
|
|
59
66
|
*/
|
|
60
67
|
hasVersions: boolean;
|
|
61
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* An upload document is a file, and no tool here can supply one.
|
|
70
|
+
*/
|
|
62
71
|
isUpload: boolean;
|
|
63
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Name of the capability group on the key document.
|
|
74
|
+
*/
|
|
64
75
|
fieldName: string;
|
|
65
76
|
}
|
|
66
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The slugs a key may read, write and publish.
|
|
79
|
+
*/
|
|
67
80
|
export interface McpxScopeSlugs {
|
|
68
81
|
readable: string[];
|
|
69
82
|
writable: string[];
|
|
70
83
|
publishable: string[];
|
|
71
84
|
}
|
|
72
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* What a tool knows about the current request.
|
|
87
|
+
*/
|
|
73
88
|
export interface McpxToolScope {
|
|
74
89
|
req: PayloadRequest;
|
|
75
90
|
capabilities: McpxResolvedCapabilities;
|
|
76
91
|
collections: McpxScopeSlugs;
|
|
77
92
|
globals: McpxScopeSlugs;
|
|
78
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* `null` when localization is off.
|
|
95
|
+
*/
|
|
79
96
|
localization: null | {
|
|
80
97
|
locales: string[];
|
|
81
98
|
defaultLocale: string;
|
|
@@ -95,9 +112,13 @@ export interface McpxToolScope {
|
|
|
95
112
|
* per request, leaving no static shape to infer from.
|
|
96
113
|
*/
|
|
97
114
|
export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
98
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* camelCase, unique, not one of the builtin tool names.
|
|
117
|
+
*/
|
|
99
118
|
name: string;
|
|
100
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Built per request so it can state what this key's writes do.
|
|
121
|
+
*/
|
|
101
122
|
description: string | ((scope: McpxToolScope) => string);
|
|
102
123
|
annotations?: ToolAnnotations;
|
|
103
124
|
/**
|
|
@@ -115,18 +136,26 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
|
|
|
115
136
|
handler(ctx: {
|
|
116
137
|
args: Args;
|
|
117
138
|
scope: McpxToolScope;
|
|
118
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Shorthand for `scope.req`.
|
|
141
|
+
*/
|
|
119
142
|
req: PayloadRequest;
|
|
120
143
|
extra: McpxToolExtra;
|
|
121
144
|
}): CallToolResult | Promise<CallToolResult>;
|
|
122
145
|
}
|
|
123
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Argument type erased, so a registry can hold tools of differing shapes.
|
|
148
|
+
*/
|
|
124
149
|
export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
|
|
125
150
|
export interface McpxAuthResult {
|
|
126
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Must carry `collection`.
|
|
153
|
+
*/
|
|
127
154
|
user: TypedUser;
|
|
128
155
|
apiKeyId: DocumentId;
|
|
129
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* The `capabilities` group as stored on the key document.
|
|
158
|
+
*/
|
|
130
159
|
capabilities: unknown;
|
|
131
160
|
}
|
|
132
161
|
/**
|
|
@@ -136,31 +165,47 @@ export interface McpxAuthResult {
|
|
|
136
165
|
* to `Record<string, unknown>`, which interfaces do not satisfy.
|
|
137
166
|
*/
|
|
138
167
|
export type McpxPluginOptions = {
|
|
139
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Allow-list of collections. `true` is shorthand for `{}`.
|
|
170
|
+
*/
|
|
140
171
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
141
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Allow-list of globals. `true` is shorthand for `{}`.
|
|
174
|
+
*/
|
|
142
175
|
globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
|
|
143
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Collection the keys act as. Default `config.admin.user`, then `users`.
|
|
178
|
+
*/
|
|
144
179
|
userCollection?: CollectionSlug;
|
|
145
180
|
apiKeys?: {
|
|
146
|
-
/**
|
|
181
|
+
/**
|
|
182
|
+
* Slug of the generated API key collection. Default `mcpx-api-keys`.
|
|
183
|
+
*/
|
|
147
184
|
slug?: string;
|
|
148
185
|
/**
|
|
149
186
|
* Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
|
|
150
187
|
* client config. Default `true`. The snippets contain the key in full.
|
|
151
188
|
*/
|
|
152
189
|
setupGuide?: boolean;
|
|
153
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Final override applied to the generated collection.
|
|
192
|
+
*/
|
|
154
193
|
overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
|
|
155
194
|
};
|
|
156
195
|
endpoint?: {
|
|
157
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Endpoint path below the API route. Default `/mcpx`.
|
|
198
|
+
*/
|
|
158
199
|
path?: string;
|
|
159
200
|
};
|
|
160
201
|
limits?: {
|
|
161
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* Upper bound for `findDocuments.limit`. Default 25.
|
|
204
|
+
*/
|
|
162
205
|
maxLimit?: number;
|
|
163
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Upper bound for `depth` on reads. Default 1.
|
|
208
|
+
*/
|
|
164
209
|
maxDepth?: number;
|
|
165
210
|
};
|
|
166
211
|
tools?: McpxAnyTool[];
|
|
@@ -180,20 +225,28 @@ export type McpxPluginOptions = {
|
|
|
180
225
|
version?: string;
|
|
181
226
|
};
|
|
182
227
|
};
|
|
183
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* What a key may do with one entity. Globals reuse this shape.
|
|
230
|
+
*/
|
|
184
231
|
export interface McpxCollectionCapabilities {
|
|
185
232
|
read: boolean;
|
|
186
233
|
write: boolean;
|
|
187
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Only ever true where the config lets writes publish and drafts exist.
|
|
236
|
+
*/
|
|
188
237
|
publish: boolean;
|
|
189
238
|
}
|
|
190
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* In force for one request: the plugin config and the key checkboxes together.
|
|
241
|
+
*/
|
|
191
242
|
export interface McpxResolvedCapabilities {
|
|
192
243
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
193
244
|
globals: Record<string, McpxCollectionCapabilities>;
|
|
194
245
|
tools: Record<string, boolean>;
|
|
195
246
|
}
|
|
196
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* Stamped on `req.context.mcpx`; see {@link isMcpxRequest}.
|
|
249
|
+
*/
|
|
197
250
|
export interface McpxRequestContext {
|
|
198
251
|
apiKeyId: DocumentId;
|
|
199
252
|
capabilities: McpxResolvedCapabilities;
|
|
@@ -6,14 +6,13 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
6
6
|
const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
7
7
|
"where",
|
|
8
8
|
"publishAllLocales",
|
|
9
|
-
"publishSpecificLocale",
|
|
10
9
|
"unpublishAllLocales",
|
|
11
10
|
"duplicateFromID",
|
|
12
11
|
"selectedLocales",
|
|
13
12
|
"overwriteExistingFiles"
|
|
14
13
|
]);
|
|
15
14
|
const scrubWriteArgs = (args, publishing) => {
|
|
16
|
-
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
15
|
+
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key) && (publishing || key !== "publishSpecificLocale")));
|
|
17
16
|
if (next["data"] && typeof next["data"] === "object") {
|
|
18
17
|
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
19
18
|
next["data"] = publishing ? {
|
|
@@ -34,17 +33,20 @@ const forceDraftWrite = (hookArgs) => {
|
|
|
34
33
|
return scrubWriteArgs(args, publishing);
|
|
35
34
|
};
|
|
36
35
|
/**
|
|
37
|
-
* The global counterpart of {@link forceDraftWrite}. `updateGlobal`
|
|
38
|
-
* `draft` and the publish arguments before
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* enforces the guard. `publishDocument` therefore passes `draft: false` at the
|
|
42
|
-
* call site, and this hook restores `_status` instead of stripping it.
|
|
36
|
+
* The global counterpart of {@link forceDraftWrite}. `updateGlobal` reads
|
|
37
|
+
* `draft` and the publish arguments before this hook runs, so only changes to
|
|
38
|
+
* `data` take effect. A write to a global with drafts that did not ask for a
|
|
39
|
+
* draft save is therefore refused here, and `_status` is restored for a publish.
|
|
43
40
|
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
44
|
-
const { operation, req } = hookArgs;
|
|
41
|
+
const { global, operation, req } = hookArgs;
|
|
45
42
|
const args = hookArgs.args;
|
|
46
43
|
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
47
|
-
|
|
44
|
+
const publishing = hasPublishIntent(args["data"]);
|
|
45
|
+
if (!publishing && args["draft"] !== true && hasDraftsEnabled(global)) {
|
|
46
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${global.slug} that did not ask for a draft save.`);
|
|
47
|
+
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
|
+
return scrubWriteArgs(args, publishing);
|
|
48
50
|
};
|
|
49
51
|
const refuseUnlessExpected = (req, slug, data) => {
|
|
50
52
|
const publishing = takePublishIntent(data);
|
|
@@ -8,7 +8,9 @@ const withPublishIntent = (data) => ({
|
|
|
8
8
|
[PUBLISH_INTENT]: TOKEN
|
|
9
9
|
});
|
|
10
10
|
const hasPublishIntent = (data) => isPlainObject(data) && data[PUBLISH_INTENT] === TOKEN;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Called by the last hook that needs the marker, which removes it.
|
|
13
|
+
*/ const takePublishIntent = (data) => {
|
|
12
14
|
if (!hasPublishIntent(data)) return false;
|
|
13
15
|
delete data[PUBLISH_INTENT];
|
|
14
16
|
return true;
|
package/dist/write/row-ids.mjs
CHANGED
|
@@ -33,7 +33,9 @@ const walkRows = (value, visit, isRow = false) => {
|
|
|
33
33
|
delete row["id"];
|
|
34
34
|
});
|
|
35
35
|
};
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* For create, where no stored row exists and any incoming id is invented.
|
|
38
|
+
*/ const stripRowIds = (value) => {
|
|
37
39
|
const next = structuredClone(value);
|
|
38
40
|
walkRows(next, (row) => {
|
|
39
41
|
delete row["id"];
|
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.9",
|
|
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",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@abinnovision/eslint-config-base": "^3.4.2",
|
|
66
66
|
"@abinnovision/prettier-config": "^2.2.0",
|
|
67
67
|
"@arethetypeswrong/core": "^0.18.5",
|
|
68
|
+
"@internal/test-utils": "0.0.0",
|
|
68
69
|
"@payloadcms/db-sqlite": "3.89.0",
|
|
69
70
|
"@payloadcms/richtext-lexical": "3.89.0",
|
|
70
71
|
"@payloadcms/ui": "3.89.0",
|