@abinnovision/payloadcms-mcpx 2.0.0-beta.7 → 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/README.md +21 -22
- 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 +8 -8
- 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/find-versions.mjs +2 -2
- 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 +81 -35
- 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 +3 -2
package/README.md
CHANGED
|
@@ -86,30 +86,29 @@ Claude Desktop and the MCP Inspector are covered in
|
|
|
86
86
|
|
|
87
87
|
## Options
|
|
88
88
|
|
|
89
|
-
| Option
|
|
90
|
-
|
|
|
91
|
-
| `collections`
|
|
92
|
-
| `collections.<slug>.read`
|
|
93
|
-
| `collections.<slug>.write`
|
|
94
|
-
| `collections.<slug>.publish`
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `apiKeys.
|
|
99
|
-
| `apiKeys.
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `limits.
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients. |
|
|
89
|
+
| Option | Default | Description |
|
|
90
|
+
| ---------------------------- | ----------------------------------------- | ------------------------------------------------------------------- |
|
|
91
|
+
| `collections` | required | Collections to expose. `true` means `{}`, everything it supports. |
|
|
92
|
+
| `collections.<slug>.read` | `true` | Expose the read tools for this collection. |
|
|
93
|
+
| `collections.<slug>.write` | `true` | Expose the write tools. Without drafts, a write goes live. |
|
|
94
|
+
| `collections.<slug>.publish` | `true` with drafts | Expose `publishDocument`. Needs `versions.drafts` and `write`. |
|
|
95
|
+
| `globals` | `{}` | Globals to expose, with the same three options. |
|
|
96
|
+
| `userCollection` | `config.admin.user`, then `users` | Auth collection whose users the keys act as. |
|
|
97
|
+
| `apiKeys.slug` | `mcpx-api-keys` | Slug of the key collection. |
|
|
98
|
+
| `apiKeys.setupGuide` | `true` | Add the "Connect a client" tab to saved keys. |
|
|
99
|
+
| `apiKeys.overrideCollection` | none | Function that receives the key collection and returns it. |
|
|
100
|
+
| `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
|
|
101
|
+
| `limits.maxLimit` | `25` | Highest `limit` a client may pass to a list tool. |
|
|
102
|
+
| `limits.maxDepth` | `1` | Highest `depth` a client may pass to a read tool. |
|
|
103
|
+
| `tools` | `[]` | Custom tools, see [`docs/custom-tools.md`](./docs/custom-tools.md). |
|
|
104
|
+
| `auth.resolve` | none | Replace or wrap the key lookup. |
|
|
105
|
+
| `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients. |
|
|
107
106
|
|
|
108
107
|
The config only takes capabilities away: `true` exposes everything the entity supports, and a key's
|
|
109
108
|
checkboxes decide what each key may do. `publish: false` keeps MCP writes as drafts. On an entity
|
|
110
|
-
without drafts, a write changes the live document.
|
|
111
|
-
|
|
112
|
-
[version history](./docs/security.md#version-history)
|
|
109
|
+
without drafts, a write changes the live document. Version history has no checkbox of its own: it
|
|
110
|
+
follows the key's read wherever the entity keeps Payload `versions`. Read
|
|
111
|
+
[version history](./docs/security.md#version-history) first.
|
|
113
112
|
Mistakes in the options fail at startup. [`docs/integration.md`](./docs/integration.md) lists
|
|
114
113
|
every check.
|
|
115
114
|
|
|
@@ -121,7 +120,7 @@ every check.
|
|
|
121
120
|
| `describeSchema` | Describes the fields at one schema path, with the paths below it. | `read` |
|
|
122
121
|
| `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read` |
|
|
123
122
|
| `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read` |
|
|
124
|
-
| `findVersions` | Lists the version history of a document or global, without bodies. | `read
|
|
123
|
+
| `findVersions` | Lists the version history of a document or global, without bodies. | `read` |
|
|
125
124
|
| `patchDocument` | Applies JSON Patch operations to the current draft. | `write` |
|
|
126
125
|
| `createDocument` | Creates a draft from a seed. Not for upload collections. | `write` |
|
|
127
126
|
| `validateDocument` | Lists what blocks publishing, without saving. | `write` |
|
|
@@ -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.`);
|
|
@@ -21,8 +23,7 @@ const assertExposable = (collection, apiKeysSlug) => {
|
|
|
21
23
|
const OPTION_NAMES = [
|
|
22
24
|
"read",
|
|
23
25
|
"write",
|
|
24
|
-
"publish"
|
|
25
|
-
"versions"
|
|
26
|
+
"publish"
|
|
26
27
|
];
|
|
27
28
|
const REMOVED_WRITE_MODES = {
|
|
28
29
|
draft: "Use { publish: false } instead.",
|
|
@@ -47,18 +48,15 @@ const normalizeCapabilities = (kind, config, raw) => {
|
|
|
47
48
|
const read = flag("read") ?? true;
|
|
48
49
|
const write = flag("write") ?? true;
|
|
49
50
|
const publish = flag("publish");
|
|
50
|
-
const versions = flag("versions") ?? false;
|
|
51
51
|
const hasDrafts = hasDraftsEnabled(config);
|
|
52
52
|
if (publish === false && write && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so every write goes live. Set write: false or remove publish: false.`);
|
|
53
53
|
if (publish === true && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so there is nothing to publish. Enable versions.drafts or remove publish: true.`);
|
|
54
54
|
if (publish === true && !write) fail(`${kind} "${slug}" has publish: true but write: false. Publishing follows write.`);
|
|
55
|
-
if (versions && !config.versions) fail(`${kind} "${slug}" keeps no versions. Enable versions or remove versions: true.`);
|
|
56
|
-
if (versions && !read) fail(`${kind} "${slug}" has versions: true but read: false. Version history follows read.`);
|
|
57
55
|
return {
|
|
58
56
|
read,
|
|
59
57
|
write: write ? publish === false ? "draft" : "live" : false,
|
|
60
58
|
hasDrafts,
|
|
61
|
-
hasVersions: versions
|
|
59
|
+
hasVersions: read && Boolean(config.versions)
|
|
62
60
|
};
|
|
63
61
|
};
|
|
64
62
|
const assertGlobalExposable = (global) => {
|
|
@@ -131,7 +129,9 @@ const normalizeLimits = (limits) => {
|
|
|
131
129
|
maxDepth
|
|
132
130
|
};
|
|
133
131
|
};
|
|
134
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Every problem is an `InvalidConfiguration`, so it fails at startup.
|
|
134
|
+
*/ const normalizeOptions = (config, options) => {
|
|
135
135
|
const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
|
|
136
136
|
const userCollection = options.userCollection ?? config.admin?.user ?? "users";
|
|
137
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 };
|
|
@@ -16,8 +16,8 @@ const statusShape = (scope) => {
|
|
|
16
16
|
/**
|
|
17
17
|
* Bodies are left out so a long history stays small; `getDocument` reads one
|
|
18
18
|
* when it is needed. The document's `read` access is checked first, then
|
|
19
|
-
* Payload's `readVersions`. Only entities
|
|
20
|
-
*
|
|
19
|
+
* Payload's `readVersions`. Only entities that keep Payload versions are
|
|
20
|
+
* reachable.
|
|
21
21
|
*/ const findVersions = defineMcpxTool({
|
|
22
22
|
name: "findVersions",
|
|
23
23
|
description: DESCRIPTION,
|
|
@@ -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
|
|
@@ -43,46 +45,54 @@ export interface McpxCollectionOptions {
|
|
|
43
45
|
* `false` keeps writes as drafts and is refused where there are none.
|
|
44
46
|
*/
|
|
45
47
|
publish?: boolean;
|
|
46
|
-
/**
|
|
47
|
-
* Expose `findVersions` and the `versionId` and `diffFrom` arguments of
|
|
48
|
-
* `getDocument` to keys that may read the entity. Default `false`: version
|
|
49
|
-
* history has no checkbox of its own and follows the key's read, so the
|
|
50
|
-
* config opts in. `true` needs Payload `versions` and `read`.
|
|
51
|
-
*/
|
|
52
|
-
versions?: boolean;
|
|
53
48
|
}
|
|
54
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The same options. A singleton, so neither `findDocuments` nor
|
|
51
|
+
* `createDocument` reaches one.
|
|
52
|
+
*/
|
|
55
53
|
export type McpxGlobalOptions = McpxCollectionOptions;
|
|
56
54
|
export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
57
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* What the config exposes, before an API key's checkboxes narrow it.
|
|
57
|
+
*/
|
|
58
58
|
export interface McpxExposedEntity {
|
|
59
59
|
slug: string;
|
|
60
60
|
read: boolean;
|
|
61
61
|
write: McpxWriteMode;
|
|
62
62
|
hasDrafts: boolean;
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
* The entity has Payload `versions`, with or without drafts, and is
|
|
65
|
+
* readable. Says nothing about drafts; see `hasDrafts`.
|
|
66
66
|
*/
|
|
67
67
|
hasVersions: boolean;
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* An upload document is a file, and no tool here can supply one.
|
|
70
|
+
*/
|
|
69
71
|
isUpload: boolean;
|
|
70
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Name of the capability group on the key document.
|
|
74
|
+
*/
|
|
71
75
|
fieldName: string;
|
|
72
76
|
}
|
|
73
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The slugs a key may read, write and publish.
|
|
79
|
+
*/
|
|
74
80
|
export interface McpxScopeSlugs {
|
|
75
81
|
readable: string[];
|
|
76
82
|
writable: string[];
|
|
77
83
|
publishable: string[];
|
|
78
84
|
}
|
|
79
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* What a tool knows about the current request.
|
|
87
|
+
*/
|
|
80
88
|
export interface McpxToolScope {
|
|
81
89
|
req: PayloadRequest;
|
|
82
90
|
capabilities: McpxResolvedCapabilities;
|
|
83
91
|
collections: McpxScopeSlugs;
|
|
84
92
|
globals: McpxScopeSlugs;
|
|
85
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* `null` when localization is off.
|
|
95
|
+
*/
|
|
86
96
|
localization: null | {
|
|
87
97
|
locales: string[];
|
|
88
98
|
defaultLocale: string;
|
|
@@ -102,9 +112,13 @@ export interface McpxToolScope {
|
|
|
102
112
|
* per request, leaving no static shape to infer from.
|
|
103
113
|
*/
|
|
104
114
|
export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
105
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* camelCase, unique, not one of the builtin tool names.
|
|
117
|
+
*/
|
|
106
118
|
name: string;
|
|
107
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Built per request so it can state what this key's writes do.
|
|
121
|
+
*/
|
|
108
122
|
description: string | ((scope: McpxToolScope) => string);
|
|
109
123
|
annotations?: ToolAnnotations;
|
|
110
124
|
/**
|
|
@@ -122,18 +136,26 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
|
|
|
122
136
|
handler(ctx: {
|
|
123
137
|
args: Args;
|
|
124
138
|
scope: McpxToolScope;
|
|
125
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Shorthand for `scope.req`.
|
|
141
|
+
*/
|
|
126
142
|
req: PayloadRequest;
|
|
127
143
|
extra: McpxToolExtra;
|
|
128
144
|
}): CallToolResult | Promise<CallToolResult>;
|
|
129
145
|
}
|
|
130
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Argument type erased, so a registry can hold tools of differing shapes.
|
|
148
|
+
*/
|
|
131
149
|
export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
|
|
132
150
|
export interface McpxAuthResult {
|
|
133
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Must carry `collection`.
|
|
153
|
+
*/
|
|
134
154
|
user: TypedUser;
|
|
135
155
|
apiKeyId: DocumentId;
|
|
136
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* The `capabilities` group as stored on the key document.
|
|
158
|
+
*/
|
|
137
159
|
capabilities: unknown;
|
|
138
160
|
}
|
|
139
161
|
/**
|
|
@@ -143,31 +165,47 @@ export interface McpxAuthResult {
|
|
|
143
165
|
* to `Record<string, unknown>`, which interfaces do not satisfy.
|
|
144
166
|
*/
|
|
145
167
|
export type McpxPluginOptions = {
|
|
146
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Allow-list of collections. `true` is shorthand for `{}`.
|
|
170
|
+
*/
|
|
147
171
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
148
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Allow-list of globals. `true` is shorthand for `{}`.
|
|
174
|
+
*/
|
|
149
175
|
globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
|
|
150
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Collection the keys act as. Default `config.admin.user`, then `users`.
|
|
178
|
+
*/
|
|
151
179
|
userCollection?: CollectionSlug;
|
|
152
180
|
apiKeys?: {
|
|
153
|
-
/**
|
|
181
|
+
/**
|
|
182
|
+
* Slug of the generated API key collection. Default `mcpx-api-keys`.
|
|
183
|
+
*/
|
|
154
184
|
slug?: string;
|
|
155
185
|
/**
|
|
156
186
|
* Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
|
|
157
187
|
* client config. Default `true`. The snippets contain the key in full.
|
|
158
188
|
*/
|
|
159
189
|
setupGuide?: boolean;
|
|
160
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Final override applied to the generated collection.
|
|
192
|
+
*/
|
|
161
193
|
overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
|
|
162
194
|
};
|
|
163
195
|
endpoint?: {
|
|
164
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Endpoint path below the API route. Default `/mcpx`.
|
|
198
|
+
*/
|
|
165
199
|
path?: string;
|
|
166
200
|
};
|
|
167
201
|
limits?: {
|
|
168
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* Upper bound for `findDocuments.limit`. Default 25.
|
|
204
|
+
*/
|
|
169
205
|
maxLimit?: number;
|
|
170
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Upper bound for `depth` on reads. Default 1.
|
|
208
|
+
*/
|
|
171
209
|
maxDepth?: number;
|
|
172
210
|
};
|
|
173
211
|
tools?: McpxAnyTool[];
|
|
@@ -187,20 +225,28 @@ export type McpxPluginOptions = {
|
|
|
187
225
|
version?: string;
|
|
188
226
|
};
|
|
189
227
|
};
|
|
190
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* What a key may do with one entity. Globals reuse this shape.
|
|
230
|
+
*/
|
|
191
231
|
export interface McpxCollectionCapabilities {
|
|
192
232
|
read: boolean;
|
|
193
233
|
write: boolean;
|
|
194
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Only ever true where the config lets writes publish and drafts exist.
|
|
236
|
+
*/
|
|
195
237
|
publish: boolean;
|
|
196
238
|
}
|
|
197
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* In force for one request: the plugin config and the key checkboxes together.
|
|
241
|
+
*/
|
|
198
242
|
export interface McpxResolvedCapabilities {
|
|
199
243
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
200
244
|
globals: Record<string, McpxCollectionCapabilities>;
|
|
201
245
|
tools: Record<string, boolean>;
|
|
202
246
|
}
|
|
203
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* Stamped on `req.context.mcpx`; see {@link isMcpxRequest}.
|
|
249
|
+
*/
|
|
204
250
|
export interface McpxRequestContext {
|
|
205
251
|
apiKeyId: DocumentId;
|
|
206
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",
|
|
@@ -101,4 +102,4 @@
|
|
|
101
102
|
"npm": true,
|
|
102
103
|
"npmAccess": "public"
|
|
103
104
|
}
|
|
104
|
-
}
|
|
105
|
+
}
|