@abinnovision/payloadcms-mcpx 1.0.0-beta.8 → 1.0.0
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/CHANGELOG.md +33 -0
- package/README.md +331 -194
- package/dist/api-keys/collection.mjs +3 -3
- package/dist/api-keys/fields.mjs +56 -5
- package/dist/api-keys/setup-guide.mjs +56 -0
- package/dist/auth/resolve.mjs +5 -7
- package/dist/capabilities.mjs +23 -4
- package/dist/client/index.d.mts +2 -0
- package/dist/client/index.mjs +2 -0
- package/dist/client/setup-guide.d.mts +14 -0
- package/dist/client/setup-guide.mjs +87 -0
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
- package/dist/endpoint/handler.mjs +11 -5
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +18 -26
- package/dist/i18n.mjs +4 -15
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -3
- package/dist/options.mjs +31 -26
- package/dist/plugin.mjs +1 -0
- package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
- package/dist/request.mjs +8 -0
- package/dist/result.d.mts +11 -0
- package/dist/result.mjs +20 -0
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +8 -0
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +195 -27
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +77 -30
- package/dist/schema/shape.mjs +133 -51
- package/dist/schema/walk.mjs +44 -64
- package/dist/tools/{index.mjs → builtin.mjs} +8 -5
- package/dist/tools/create-document.mjs +34 -15
- package/dist/tools/describe-schema.mjs +21 -7
- package/dist/tools/find-documents.mjs +13 -6
- package/dist/tools/get-document.mjs +45 -11
- package/dist/tools/list-capabilities.mjs +19 -9
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +32 -21
- package/dist/tools/publish-document.mjs +79 -0
- package/dist/tools/shared.mjs +84 -32
- package/dist/tools/target.mjs +7 -11
- package/dist/tools/validate-document.mjs +20 -12
- package/dist/types.d.mts +115 -42
- package/dist/types.mjs +3 -4
- package/dist/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +47 -44
- package/dist/write/patch.mjs +174 -92
- package/dist/write/publish-blockers.mjs +13 -12
- package/dist/write/publish-intent.mjs +17 -0
- package/dist/write/transaction.mjs +8 -3
- package/package.json +24 -9
- package/dist/i18n.d.mts +0 -1
- package/dist/options.d.mts +0 -2
- package/dist/schema/lexical.d.mts +0 -1
- package/dist/schema/walk.d.mts +0 -3
- package/dist/tools/target.d.mts +0 -3
- package/dist/tools/types.d.mts +0 -5
- package/dist/write/publish-blockers.d.mts +0 -15
package/dist/tools/shared.mjs
CHANGED
|
@@ -1,48 +1,102 @@
|
|
|
1
|
+
import { canCreate, canPublish, isLiveWrite } from "../capabilities.mjs";
|
|
1
2
|
import { translateStatic } from "../i18n.mjs";
|
|
2
3
|
import { NotFound } from "payload";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
//#region src/tools/shared.ts
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
/**
|
|
7
|
+
* An out-of-scope slug fails schema validation before a handler runs, so a
|
|
8
|
+
* client only ever sees what its key may touch.
|
|
9
|
+
*/ const slugEnum = (slugs) => z.enum(slugs);
|
|
10
|
+
/** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
11
|
+
const slugsWhere = (scope, predicate, allowed) => {
|
|
12
|
+
const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
|
|
13
|
+
return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Slugs this key may write whose writes land live rather than as a draft. An
|
|
17
|
+
* entity without versions has no draft to land on, so `write: "live"` there
|
|
18
|
+
* makes every write a live one. Empty for every key that can only write drafts.
|
|
19
|
+
*/ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
|
|
20
|
+
collections: scope.writable,
|
|
21
|
+
globals: scope.writableGlobals
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Slugs this key may write but never create in, because their documents are
|
|
25
|
+
* files. Collection-only, since nothing creates a global either way.
|
|
26
|
+
*/ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
|
|
27
|
+
collections: scope.writable,
|
|
28
|
+
globals: []
|
|
29
|
+
});
|
|
30
|
+
/** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
|
|
31
|
+
collections: scope.publishable,
|
|
32
|
+
globals: scope.publishableGlobals
|
|
10
33
|
});
|
|
11
34
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
35
|
+
* What a write actually does for this key, and what it takes to make it public.
|
|
36
|
+
* A live-write slug has no draft and no publish step; a publishable one has
|
|
37
|
+
* both. Stated per key so a client is never told its writes are drafts while
|
|
38
|
+
* they are not, nor that publishing is out of reach when it is not.
|
|
39
|
+
*/ const draftSentence = (scope) => {
|
|
40
|
+
const live = liveWriteSlugs(scope);
|
|
41
|
+
const publishable = publishableWriteSlugs(scope);
|
|
42
|
+
return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
|
|
43
|
+
};
|
|
44
|
+
/** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
45
|
+
/** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
|
|
46
|
+
/** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
|
|
47
|
+
switch (operation) {
|
|
48
|
+
case "create": return {
|
|
49
|
+
collections: slugsWhere(scope, canCreate, {
|
|
50
|
+
collections: scope.writable,
|
|
51
|
+
globals: []
|
|
52
|
+
}),
|
|
53
|
+
globals: []
|
|
54
|
+
};
|
|
55
|
+
case "publish": return {
|
|
56
|
+
collections: scope.publishable,
|
|
57
|
+
globals: scope.publishableGlobals
|
|
58
|
+
};
|
|
59
|
+
case "read": return {
|
|
60
|
+
collections: scope.readable,
|
|
61
|
+
globals: scope.readableGlobals
|
|
62
|
+
};
|
|
63
|
+
case "write": return {
|
|
64
|
+
collections: scope.writable,
|
|
65
|
+
globals: scope.writableGlobals
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* With no reachable global, `global` is left out and `collection` stays
|
|
71
|
+
* required, so a deployment without globals sees an unchanged schema. Only the
|
|
72
|
+
* mixed case makes either optional, and the handler enforces exclusivity there.
|
|
19
73
|
*/ const targetShape = (scope, operation, descriptions) => {
|
|
20
74
|
const { collections, globals } = slugsFor(scope, operation);
|
|
21
|
-
if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
|
|
22
|
-
if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
|
|
23
|
-
return {
|
|
75
|
+
if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
|
|
76
|
+
if (collections.length === 0) return widen({ global: slugEnum(globals).describe(descriptions.global) });
|
|
77
|
+
return widen({
|
|
24
78
|
collection: slugEnum(collections).optional().describe(descriptions.collection),
|
|
25
79
|
global: slugEnum(globals).optional().describe(descriptions.global)
|
|
26
|
-
};
|
|
80
|
+
});
|
|
27
81
|
};
|
|
28
82
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* in between, where `requireIdFor` enforces the dependency.
|
|
83
|
+
* Only a collection document has one. Optional in the mixed case, where
|
|
84
|
+
* `requireIdFor` enforces the dependency.
|
|
32
85
|
*/ const idShape = (scope, operation) => {
|
|
33
86
|
const { collections, globals } = slugsFor(scope, operation);
|
|
34
|
-
if (collections.length === 0) return {};
|
|
35
|
-
if (globals.length === 0) return { id: idSchema };
|
|
36
|
-
return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
|
|
87
|
+
if (collections.length === 0) return widen({});
|
|
88
|
+
if (globals.length === 0) return widen({ id: idSchema });
|
|
89
|
+
return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
|
|
37
90
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*/ const localeShape = (scope, options) => {
|
|
41
|
-
if (!scope.locales) return {};
|
|
91
|
+
const localeShape = (scope, options) => {
|
|
92
|
+
if (!scope.locales) return widen({});
|
|
42
93
|
const locale = z.enum(scope.locales);
|
|
43
|
-
return { locale: (options.required ? locale : locale.optional()).describe(options.description) };
|
|
94
|
+
return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
|
|
44
95
|
};
|
|
45
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Defaults to 0 rather than Payload's own default: a client usually wants ids
|
|
98
|
+
* it can write back, and populating a relation costs a query.
|
|
99
|
+
*/ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
|
|
46
100
|
/**
|
|
47
101
|
* The locale to operate on: the explicit argument, else the request's, else
|
|
48
102
|
* the default. `undefined` when localization is off.
|
|
@@ -83,9 +137,7 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
|
|
|
83
137
|
slug: args.target.slug
|
|
84
138
|
});
|
|
85
139
|
};
|
|
86
|
-
|
|
87
|
-
* Resolves a collection label for the request's language.
|
|
88
|
-
*/ const translateLabel = (scope, label, fallback) => {
|
|
140
|
+
const translateLabel = (scope, label, fallback) => {
|
|
89
141
|
const { i18n, t } = scope.req;
|
|
90
142
|
const resolved = typeof label === "function" ? label({
|
|
91
143
|
i18n,
|
|
@@ -94,4 +146,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
|
|
|
94
146
|
return translateStatic(resolved, i18n) ?? fallback;
|
|
95
147
|
};
|
|
96
148
|
//#endregion
|
|
97
|
-
export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
|
|
149
|
+
export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, patchOnlySlugs, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
|
package/dist/tools/target.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { slugsFor } from "./shared.mjs";
|
|
1
2
|
import { APIError, Forbidden } from "payload";
|
|
2
3
|
//#region src/tools/target.ts
|
|
3
4
|
const refOf = (target) => ({
|
|
@@ -5,21 +6,17 @@ const refOf = (target) => ({
|
|
|
5
6
|
slug: target.slug
|
|
6
7
|
});
|
|
7
8
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* A tool's `inputSchema` returns a raw shape, which leaves no top-level
|
|
12
|
-
* `.refine` to express "exactly one of collection and global". The rule is
|
|
13
|
-
* enforced here instead, with a message naming the offending arguments so one
|
|
14
|
-
* failed call teaches it.
|
|
9
|
+
* A raw input shape leaves no top-level `.refine` to express "exactly one of
|
|
10
|
+
* collection and global", so the rule is enforced here, with a message naming
|
|
11
|
+
* the offending arguments.
|
|
15
12
|
*/ const resolveTarget = (scope, args, operation) => {
|
|
16
13
|
const { collection, global } = args;
|
|
14
|
+
const allowedSlugs = slugsFor(scope, operation);
|
|
17
15
|
if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
|
|
18
16
|
if (collection === void 0 && global === void 0) throw new APIError("One of \"collection\" or \"global\" is required. Call listCapabilities to see which slugs are available.", 400);
|
|
19
17
|
if (collection !== void 0) {
|
|
20
|
-
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
21
18
|
const found = scope.req.payload.collections[collection];
|
|
22
|
-
if (!
|
|
19
|
+
if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
|
|
23
20
|
return {
|
|
24
21
|
kind: "collection",
|
|
25
22
|
slug: collection,
|
|
@@ -27,9 +24,8 @@ const refOf = (target) => ({
|
|
|
27
24
|
};
|
|
28
25
|
}
|
|
29
26
|
const slug = global;
|
|
30
|
-
const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
|
|
31
27
|
const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
|
|
32
|
-
if (!
|
|
28
|
+
if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
|
|
33
29
|
return {
|
|
34
30
|
kind: "global",
|
|
35
31
|
slug,
|
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
import { jsonResult } from "../
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
2
2
|
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
3
|
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
4
5
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Gated on write rather than read, because publish blockers only mean
|
|
8
|
+
* something to a caller who can act on them.
|
|
9
|
+
*
|
|
10
|
+
* It reads the document twice on purpose: once under the key's own access to
|
|
11
|
+
* refuse a caller who may not see it, then privileged, so the check runs over
|
|
12
|
+
* every field rather than the subset the user can read. It carries no
|
|
13
|
+
* `readOnlyHint`, because the traversal fires field hooks.
|
|
14
|
+
*/ const validateDocument = defineMcpxTool({
|
|
7
15
|
name: "validateDocument",
|
|
8
16
|
description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
|
|
9
17
|
|
|
10
|
-
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
},
|
|
18
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
19
|
+
|
|
20
|
+
Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
|
|
21
|
+
annotations: { openWorldHint: false },
|
|
15
22
|
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
16
23
|
inputSchema: (scope) => ({
|
|
17
24
|
...targetShape(scope, "write", {
|
|
@@ -24,7 +31,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
24
31
|
description: "Locale to validate."
|
|
25
32
|
})
|
|
26
33
|
}),
|
|
27
|
-
handler: async (args, scope) => {
|
|
34
|
+
handler: async ({ args, scope }) => {
|
|
28
35
|
const target = resolveTarget(scope, args, "write");
|
|
29
36
|
const id = requireIdFor(target, args.id);
|
|
30
37
|
const locale = localeOf(scope, args.locale);
|
|
@@ -39,7 +46,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
39
46
|
locale,
|
|
40
47
|
privileged: true
|
|
41
48
|
});
|
|
42
|
-
const
|
|
49
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
43
50
|
doc,
|
|
44
51
|
entity: target
|
|
45
52
|
});
|
|
@@ -47,9 +54,10 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
47
54
|
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
48
55
|
status: doc["_status"],
|
|
49
56
|
updatedAt: doc["updatedAt"],
|
|
50
|
-
publishBlockers
|
|
57
|
+
publishBlockers: validation.blockers,
|
|
58
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {}
|
|
51
59
|
});
|
|
52
60
|
}
|
|
53
|
-
};
|
|
61
|
+
});
|
|
54
62
|
//#endregion
|
|
55
63
|
export { validateDocument };
|
package/dist/types.d.mts
CHANGED
|
@@ -12,74 +12,132 @@ declare module "payload" {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* How far an exposed entity lets MCP writes reach.
|
|
16
|
+
*
|
|
17
|
+
* - `false`: no write tool touches it.
|
|
18
|
+
* - `"draft"`: writes land as drafts and nothing MCP does changes what the
|
|
19
|
+
* public sees. Requires `versions.drafts`.
|
|
20
|
+
* - `"live"`: MCP may change live content. On an entity with drafts that means
|
|
21
|
+
* `publishDocument` is exposed; on one without, where there is no draft to
|
|
22
|
+
* land on, it means the write itself is permitted and lands live.
|
|
17
23
|
*/
|
|
24
|
+
type McpxWriteMode = "draft" | "live" | false;
|
|
25
|
+
/** A key can only enable what the config exposes here. */
|
|
18
26
|
interface McpxCollectionOptions {
|
|
19
|
-
/** Expose `describeSchema`, `findDocuments
|
|
27
|
+
/** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
|
|
20
28
|
read?: boolean;
|
|
21
29
|
/**
|
|
22
|
-
* Expose `patchDocument`, `
|
|
23
|
-
* `
|
|
30
|
+
* Expose `patchDocument`, `validateDocument` and, unless this is an upload
|
|
31
|
+
* collection, `createDocument`, and how far those writes reach. Default
|
|
32
|
+
* `false`.
|
|
24
33
|
*/
|
|
25
|
-
write?:
|
|
26
|
-
/**
|
|
27
|
-
* Permit writes to a collection without drafts. Such writes land on the live
|
|
28
|
-
* document because there is no draft to land on. Default `false`.
|
|
29
|
-
*/
|
|
30
|
-
allowLiveWrites?: boolean;
|
|
34
|
+
write?: McpxWriteMode;
|
|
31
35
|
}
|
|
32
|
-
/**
|
|
33
|
-
* What an exposed global offers to MCP clients. Structurally the same as
|
|
34
|
-
* {@link McpxCollectionOptions}, kept separate because the tools it names
|
|
35
|
-
* differ: a global is a singleton, so neither `findDocuments` nor
|
|
36
|
-
* `createDocument` reaches one.
|
|
37
|
-
*/
|
|
36
|
+
/** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
|
|
38
37
|
interface McpxGlobalOptions {
|
|
39
38
|
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
40
39
|
read?: boolean;
|
|
41
40
|
/**
|
|
42
|
-
* Expose `patchDocument` and `validateDocument
|
|
43
|
-
*
|
|
41
|
+
* Expose `patchDocument` and `validateDocument`, and how far those writes
|
|
42
|
+
* reach. Default `false`.
|
|
44
43
|
*/
|
|
45
|
-
write?:
|
|
46
|
-
/**
|
|
47
|
-
* Permit writes to a global without drafts. Such writes land on the live
|
|
48
|
-
* document because there is no draft to land on. Default `false`.
|
|
49
|
-
*/
|
|
50
|
-
allowLiveWrites?: boolean;
|
|
44
|
+
write?: McpxWriteMode;
|
|
51
45
|
}
|
|
52
46
|
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
47
|
+
/** What the config exposes, before an API key's checkboxes narrow it. */
|
|
48
|
+
interface McpxExposedEntity {
|
|
49
|
+
slug: string;
|
|
50
|
+
read: boolean;
|
|
51
|
+
write: McpxWriteMode;
|
|
52
|
+
hasDrafts: boolean;
|
|
53
|
+
/** An upload document is a file, and no tool here can supply one. */
|
|
54
|
+
isUpload: boolean;
|
|
55
|
+
/** Name of the capability group on the key document. */
|
|
56
|
+
fieldName: string;
|
|
57
|
+
}
|
|
58
|
+
/** What a tool knows about the current request. */
|
|
59
|
+
interface McpxToolScope {
|
|
60
|
+
req: PayloadRequest;
|
|
61
|
+
capabilities: McpxResolvedCapabilities;
|
|
62
|
+
readable: string[];
|
|
63
|
+
writable: string[];
|
|
64
|
+
publishable: string[];
|
|
65
|
+
readableGlobals: string[];
|
|
66
|
+
writableGlobals: string[];
|
|
67
|
+
publishableGlobals: string[];
|
|
68
|
+
/** `null` when localization is off. */
|
|
69
|
+
locales: null | string[];
|
|
70
|
+
defaultLocale: null | string;
|
|
71
|
+
limits: {
|
|
72
|
+
maxLimit: number;
|
|
73
|
+
maxDepth: number;
|
|
74
|
+
};
|
|
75
|
+
exposure: {
|
|
76
|
+
collections: McpxExposedEntity[];
|
|
77
|
+
globals: McpxExposedEntity[];
|
|
78
|
+
};
|
|
79
|
+
}
|
|
53
80
|
/**
|
|
54
|
-
* A
|
|
55
|
-
*
|
|
81
|
+
* A tool, builtin or custom. Runs with `req.user` resolved from the key and
|
|
82
|
+
* `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
|
|
83
|
+
* per request, leaving no static shape to infer from.
|
|
56
84
|
*/
|
|
57
|
-
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape
|
|
85
|
+
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
58
86
|
/** camelCase, unique, not one of the builtin tool names. */
|
|
59
87
|
name: string;
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
/** Built per request so it can state what this key's writes actually do. */
|
|
89
|
+
description: string | ((scope: McpxToolScope) => string);
|
|
62
90
|
annotations?: ToolAnnotations;
|
|
91
|
+
/**
|
|
92
|
+
* A tool that is not enabled never appears in `tools/list`. Defaults to the
|
|
93
|
+
* tool's own checkbox on the API key; defining it replaces that check rather
|
|
94
|
+
* than adding to it.
|
|
95
|
+
*/
|
|
96
|
+
isEnabled?: (scope: McpxToolScope) => boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Built per request so enums can be narrowed to what the key may touch.
|
|
99
|
+
* Registered strictly either way: an unknown argument is rejected by name
|
|
100
|
+
* rather than stripped.
|
|
101
|
+
*/
|
|
102
|
+
inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
|
|
63
103
|
handler(ctx: {
|
|
64
|
-
args:
|
|
104
|
+
args: Args;
|
|
105
|
+
scope: McpxToolScope;
|
|
106
|
+
/** Shorthand for `scope.req`. */
|
|
65
107
|
req: PayloadRequest;
|
|
66
108
|
extra: McpxToolExtra;
|
|
67
109
|
}): CallToolResult | Promise<CallToolResult>;
|
|
68
110
|
}
|
|
111
|
+
/** Argument type erased, so a registry can hold tools of differing shapes. */
|
|
112
|
+
type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
|
|
113
|
+
/** Fixed shape; arguments inferred from it. */
|
|
114
|
+
declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
115
|
+
inputSchema?: Shape;
|
|
116
|
+
}): McpxTool<Shape>;
|
|
117
|
+
/** Per-request shape returned as an object literal; arguments inferred from it. */
|
|
118
|
+
declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
119
|
+
inputSchema: (scope: McpxToolScope) => Shape;
|
|
120
|
+
}): McpxAnyTool;
|
|
69
121
|
/**
|
|
70
|
-
*
|
|
71
|
-
* from
|
|
72
|
-
*/
|
|
73
|
-
declare const defineMcpxTool: <Shape extends z.ZodRawShape>(tool: McpxTool<Shape>) => McpxTool<Shape>;
|
|
74
|
-
/**
|
|
75
|
-
* Outcome of resolving an API key. `user` must carry `collection`.
|
|
122
|
+
* Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
|
|
123
|
+
* builtins do. Nothing to infer from, so state the arguments instead.
|
|
76
124
|
*/
|
|
125
|
+
declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
|
|
126
|
+
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
127
|
+
}): McpxAnyTool;
|
|
77
128
|
interface McpxAuthResult {
|
|
129
|
+
/** Must carry `collection`. */
|
|
78
130
|
user: TypedUser;
|
|
79
131
|
apiKeyId: number | string;
|
|
80
132
|
/** The `capabilities` group as stored on the key document. */
|
|
81
133
|
capabilities: unknown;
|
|
82
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Everything the plugin accepts. `collections` is the only required option.
|
|
137
|
+
*
|
|
138
|
+
* A type alias rather than an interface: `definePlugin` constrains its options
|
|
139
|
+
* to `Record<string, unknown>`, which interfaces do not satisfy.
|
|
140
|
+
*/
|
|
83
141
|
type McpxPluginOptions = {
|
|
84
142
|
/** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
|
|
85
143
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
@@ -90,6 +148,11 @@ type McpxPluginOptions = {
|
|
|
90
148
|
apiKeys?: {
|
|
91
149
|
/** Slug of the generated API key collection. Default `mcpx-api-keys`. */
|
|
92
150
|
slug?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
|
|
153
|
+
* client config. Default `true`. The snippets contain the key in full.
|
|
154
|
+
*/
|
|
155
|
+
setupGuide?: boolean;
|
|
93
156
|
/** Final override applied to the generated collection. */
|
|
94
157
|
overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
|
|
95
158
|
};
|
|
@@ -103,7 +166,7 @@ type McpxPluginOptions = {
|
|
|
103
166
|
/** Upper bound for `depth` on reads. Default 1. */
|
|
104
167
|
maxDepth?: number;
|
|
105
168
|
};
|
|
106
|
-
tools?:
|
|
169
|
+
tools?: McpxAnyTool[];
|
|
107
170
|
auth?: {
|
|
108
171
|
/** Replace or wrap the default key resolution. Return `null` for 401. */
|
|
109
172
|
resolve?: (args: {
|
|
@@ -116,21 +179,31 @@ type McpxPluginOptions = {
|
|
|
116
179
|
version?: string;
|
|
117
180
|
};
|
|
118
181
|
};
|
|
182
|
+
/** What a key may do with one entity. Globals reuse this shape. */
|
|
119
183
|
interface McpxCollectionCapabilities {
|
|
120
184
|
read: boolean;
|
|
121
185
|
write: boolean;
|
|
186
|
+
/** Only ever true where the config sets `write: "live"` and drafts exist. */
|
|
187
|
+
publish: boolean;
|
|
122
188
|
}
|
|
123
|
-
/**
|
|
124
|
-
* Capabilities in force for one request: plugin config AND key checkboxes.
|
|
125
|
-
*/
|
|
189
|
+
/** In force for one request: plugin config AND key checkboxes. */
|
|
126
190
|
interface McpxResolvedCapabilities {
|
|
127
191
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
128
192
|
globals: Record<string, McpxCollectionCapabilities>;
|
|
129
193
|
tools: Record<string, boolean>;
|
|
130
194
|
}
|
|
195
|
+
/** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
|
|
131
196
|
interface McpxRequestContext {
|
|
132
197
|
apiKeyId: number | string;
|
|
133
198
|
capabilities: McpxResolvedCapabilities;
|
|
134
199
|
}
|
|
200
|
+
/** One reason a human could not publish the draft as it stands. */
|
|
201
|
+
interface PublishBlocker {
|
|
202
|
+
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
203
|
+
field?: string;
|
|
204
|
+
message: string;
|
|
205
|
+
/** JSON Pointer to the offending value, e.g. "/layout/2/title". */
|
|
206
|
+
path: string;
|
|
207
|
+
}
|
|
135
208
|
//#endregion
|
|
136
|
-
export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
|
|
209
|
+
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool };
|
package/dist/types.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
//#region src/types.ts
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/ const defineMcpxTool = (tool) => tool;
|
|
2
|
+
function defineMcpxTool(tool) {
|
|
3
|
+
return tool;
|
|
4
|
+
}
|
|
6
5
|
//#endregion
|
|
7
6
|
export { defineMcpxTool };
|
package/dist/version.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { hasPublishIntent, takePublishIntent } from "./publish-intent.mjs";
|
|
2
|
+
import { isMcpxRequest } from "../request.mjs";
|
|
1
3
|
import { APIError } from "payload";
|
|
2
4
|
import { hasDraftsEnabled } from "payload/shared";
|
|
3
5
|
//#region src/write/draft-guard.ts
|
|
4
|
-
/**
|
|
5
|
-
* Operation arguments that widen or redirect a write. Cleared on every MCP
|
|
6
|
-
* create and update so a tool cannot smuggle them in.
|
|
7
|
-
*/ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
6
|
+
/** Cleared on every MCP write, publishes included, so none can be smuggled in. */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
8
7
|
"where",
|
|
9
8
|
"publishAllLocales",
|
|
10
9
|
"publishSpecificLocale",
|
|
@@ -14,26 +13,28 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
14
13
|
"overwriteExistingFiles"
|
|
15
14
|
]);
|
|
16
15
|
/**
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
* same `req`, including those made by custom tools.
|
|
20
|
-
*/ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
|
|
21
|
-
/**
|
|
22
|
-
* Forces every MCP write into a draft save.
|
|
16
|
+
* Forces every MCP write into a draft save, unless it is the one write
|
|
17
|
+
* `publishDocument` asked for.
|
|
23
18
|
*
|
|
24
19
|
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
25
20
|
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* the
|
|
30
|
-
|
|
21
|
+
* Writing it here rather than in the tool keeps the tool honest, since this is
|
|
22
|
+
* the only thing that can grant a publish.
|
|
23
|
+
*
|
|
24
|
+
* Not covered: deletes, `duplicate`, files (the local API lifts `file` and
|
|
25
|
+
* `filePath` onto `req` before this runs), and anything going straight to
|
|
26
|
+
* `payload.db`. `restoreVersion` is caught by {@link refusePublish} instead,
|
|
27
|
+
* because it runs the collection's `beforeChange` hooks.
|
|
28
|
+
*/ const scrubWriteArgs = (args, publishing) => {
|
|
31
29
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
32
30
|
if (next["data"] && typeof next["data"] === "object") {
|
|
33
31
|
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
34
|
-
next["data"] =
|
|
32
|
+
next["data"] = publishing ? {
|
|
33
|
+
...data,
|
|
34
|
+
_status: "published"
|
|
35
|
+
} : data;
|
|
35
36
|
}
|
|
36
|
-
next["draft"] =
|
|
37
|
+
next["draft"] = !publishing;
|
|
37
38
|
next["autosave"] = false;
|
|
38
39
|
next["overrideLock"] = false;
|
|
39
40
|
next["trash"] = false;
|
|
@@ -42,52 +43,54 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
42
43
|
const forceDraftWrite = (hookArgs) => {
|
|
43
44
|
const { args, operation, req } = hookArgs;
|
|
44
45
|
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
45
|
-
|
|
46
|
+
const publishing = operation === "update" && hasPublishIntent(args.data);
|
|
47
|
+
return scrubWriteArgs(args, publishing);
|
|
46
48
|
};
|
|
47
49
|
/**
|
|
48
|
-
* The global counterpart of {@link forceDraftWrite}
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* exists, so only `update` is intercepted. `STRIPPED_ARGS` covers the three
|
|
56
|
-
* publish vectors `updateGlobal` accepts; the rest of the set does not exist on
|
|
57
|
-
* that signature and filtering it is a harmless no-op. `slug` survives the
|
|
58
|
-
* filter, so the operation still knows what it is updating.
|
|
50
|
+
* The global counterpart of {@link forceDraftWrite}, with one difference that
|
|
51
|
+
* decides where the guarantee lives: `updateGlobal` destructures `draft` and
|
|
52
|
+
* the publish arguments *before* it runs `beforeOperation` and re-reads only
|
|
53
|
+
* `data` afterwards, so setting them here is a no-op. What lands is `data` with
|
|
54
|
+
* `_status` stripped, which makes {@link refusePublishGlobal} the alarm that
|
|
55
|
+
* actually holds the line. `publishDocument` therefore passes `draft: false` at
|
|
56
|
+
* the call site, and this hook puts `_status` back rather than stripping it.
|
|
59
57
|
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
60
58
|
const { operation, req } = hookArgs;
|
|
61
59
|
const args = hookArgs.args;
|
|
62
60
|
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
63
|
-
return scrubWriteArgs(args);
|
|
61
|
+
return scrubWriteArgs(args, hasPublishIntent(args["data"]));
|
|
64
62
|
};
|
|
65
63
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*/ const
|
|
64
|
+
* Throws instead of correcting `_status`, because Payload has already chosen
|
|
65
|
+
* the write branch by the time a `beforeChange` hook runs. Unreachable for a
|
|
66
|
+
* collection if {@link forceDraftWrite} did its job; the guarantee itself for a
|
|
67
|
+
* global. Last hook that needs the marker, so it takes it off.
|
|
68
|
+
*/ const refuseUnlessExpected = (req, slug, data) => {
|
|
69
|
+
const publishing = takePublishIntent(data);
|
|
71
70
|
if (!isMcpxRequest(req)) return;
|
|
72
71
|
const status = data._status;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
const expected = publishing ? "published" : "draft";
|
|
73
|
+
if (status === expected) return;
|
|
74
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
|
|
75
|
+
throw new APIError(publishing ? "This publish was refused because it would not have saved a published document." : "MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
|
|
76
76
|
};
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Installs {@link refuseUnlessExpected} on every collection write. Returns
|
|
79
|
+
* `data` unchanged when the write is allowed; the hook exists for its throw.
|
|
80
|
+
*/ const refusePublish = ({ collection, data, req }) => {
|
|
81
|
+
refuseUnlessExpected(req, collection.slug, data);
|
|
79
82
|
return data;
|
|
80
83
|
};
|
|
81
|
-
|
|
84
|
+
const refusePublishGlobal = ({ data, global, req }) => {
|
|
82
85
|
const next = data;
|
|
83
|
-
|
|
86
|
+
refuseUnlessExpected(req, global.slug, next);
|
|
84
87
|
return next;
|
|
85
88
|
};
|
|
86
89
|
/**
|
|
87
90
|
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
88
91
|
* (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
|
|
89
92
|
* exist. Applied to the built collection list so nothing can join later
|
|
90
|
-
* without being covered.
|
|
93
|
+
* without being covered. Both are appended last, so a user hook cannot win.
|
|
91
94
|
*/ const installDraftGuards = (collections) => collections.map((collection) => ({
|
|
92
95
|
...collection,
|
|
93
96
|
hooks: {
|
|
@@ -110,4 +113,4 @@ const refusePublish = ({ collection, data, req }) => {
|
|
|
110
113
|
}
|
|
111
114
|
}));
|
|
112
115
|
//#endregion
|
|
113
|
-
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards,
|
|
116
|
+
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };
|