@abinnovision/payloadcms-mcpx 1.0.0-beta.4 → 1.0.0-beta.6
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 +77 -17
- package/dist/api-keys/fields.mjs +27 -12
- package/dist/capabilities.mjs +16 -3
- package/dist/endpoint/handler.mjs +3 -1
- package/dist/endpoint/server.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/options.d.mts +2 -0
- package/dist/options.mjs +38 -0
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +3 -2
- package/dist/schema/describe.mjs +109 -28
- package/dist/schema/lexical.mjs +50 -2
- package/dist/schema/pointer.mjs +2 -2
- package/dist/schema/shape.mjs +67 -14
- package/dist/schema/walk.d.mts +1 -0
- package/dist/schema/walk.mjs +15 -4
- package/dist/tools/create-document.mjs +9 -8
- package/dist/tools/describe-schema.mjs +14 -6
- package/dist/tools/find-documents.mjs +4 -3
- package/dist/tools/get-document.mjs +24 -11
- package/dist/tools/list-capabilities.mjs +19 -1
- package/dist/tools/patch-document.mjs +34 -20
- package/dist/tools/shared.mjs +54 -21
- package/dist/tools/target.d.mts +3 -0
- package/dist/tools/target.mjs +49 -0
- package/dist/tools/types.d.mts +5 -0
- package/dist/tools/validate-document.mjs +22 -15
- package/dist/types.d.mts +25 -2
- package/dist/write/draft-guard.mjs +51 -8
- package/dist/write/patch.mjs +4 -4
- package/dist/write/publish-blockers.d.mts +1 -0
- package/dist/write/publish-blockers.mjs +8 -7
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { APIError, Forbidden } from "payload";
|
|
2
|
+
//#region src/tools/target.ts
|
|
3
|
+
const refOf = (target) => ({
|
|
4
|
+
kind: target.kind,
|
|
5
|
+
slug: target.slug
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the `collection`/`global` arguments to one entity and checks the key
|
|
9
|
+
* may perform `operation` on it.
|
|
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.
|
|
15
|
+
*/ const resolveTarget = (scope, args, operation) => {
|
|
16
|
+
const { collection, global } = args;
|
|
17
|
+
if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
|
|
18
|
+
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
|
+
if (collection !== void 0) {
|
|
20
|
+
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
21
|
+
const found = scope.req.payload.collections[collection];
|
|
22
|
+
if (!allowed.includes(collection) || !found) throw new Forbidden(scope.req.t);
|
|
23
|
+
return {
|
|
24
|
+
kind: "collection",
|
|
25
|
+
slug: collection,
|
|
26
|
+
config: found.config
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const slug = global;
|
|
30
|
+
const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
|
|
31
|
+
const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
|
|
32
|
+
if (!allowed.includes(slug) || !found) throw new Forbidden(scope.req.t);
|
|
33
|
+
return {
|
|
34
|
+
kind: "global",
|
|
35
|
+
slug,
|
|
36
|
+
config: found
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Checks `id` against the resolved target. A collection document needs one; a
|
|
41
|
+
* global is a singleton and must not carry one. The schema cannot express the
|
|
42
|
+
* dependency, so it is stated here and in every affected tool description.
|
|
43
|
+
*/ const requireIdFor = (target, id) => {
|
|
44
|
+
if (target.kind === "collection" && id === void 0) throw new APIError(`"id" is required when "collection" is "${target.slug}".`, 400);
|
|
45
|
+
if (target.kind === "global" && id !== void 0) throw new APIError(`"id" must be omitted when "global" is "${target.slug}"; a global is a singleton.`, 400);
|
|
46
|
+
return target.kind === "collection" ? id : void 0;
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { refOf, requireIdFor, resolveTarget };
|
|
@@ -1,43 +1,50 @@
|
|
|
1
1
|
import { jsonResult } from "../endpoint/result.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
|
+
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
3
4
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
4
5
|
//#region src/tools/validate-document.ts
|
|
5
6
|
const validateDocument = {
|
|
6
7
|
name: "validateDocument",
|
|
7
|
-
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"
|
|
8
|
+
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
|
+
|
|
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.`,
|
|
8
11
|
annotations: {
|
|
9
12
|
readOnlyHint: true,
|
|
10
13
|
openWorldHint: false
|
|
11
14
|
},
|
|
12
|
-
isEnabled: (scope) => scope.writable.length > 0,
|
|
15
|
+
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
13
16
|
inputSchema: (scope) => ({
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
...targetShape(scope, "write", {
|
|
18
|
+
collection: "Collection holding the document.",
|
|
19
|
+
global: "Global to validate."
|
|
20
|
+
}),
|
|
21
|
+
...idShape(scope, "write"),
|
|
16
22
|
...localeShape(scope, {
|
|
17
23
|
required: true,
|
|
18
24
|
description: "Locale to validate."
|
|
19
25
|
})
|
|
20
26
|
}),
|
|
21
27
|
handler: async (args, scope) => {
|
|
22
|
-
const
|
|
28
|
+
const target = resolveTarget(scope, args, "write");
|
|
29
|
+
const id = requireIdFor(target, args.id);
|
|
23
30
|
const locale = localeOf(scope, args.locale);
|
|
24
|
-
await
|
|
25
|
-
|
|
26
|
-
id
|
|
31
|
+
await readTarget(scope, {
|
|
32
|
+
target,
|
|
33
|
+
id,
|
|
27
34
|
locale
|
|
28
35
|
});
|
|
29
|
-
const doc = await
|
|
30
|
-
|
|
31
|
-
id
|
|
36
|
+
const doc = await readTarget(scope, {
|
|
37
|
+
target,
|
|
38
|
+
id,
|
|
32
39
|
locale,
|
|
33
40
|
privileged: true
|
|
34
41
|
});
|
|
35
42
|
const publishBlockers = await collectPublishBlockers(scope.req, {
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
doc,
|
|
44
|
+
entity: target
|
|
38
45
|
});
|
|
39
46
|
return jsonResult({
|
|
40
|
-
id: doc["id"],
|
|
47
|
+
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
41
48
|
status: doc["_status"],
|
|
42
49
|
updatedAt: doc["updatedAt"],
|
|
43
50
|
publishBlockers
|
package/dist/types.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CollectionConfig, CollectionSlug, PayloadRequest, TypedUser } from "payload";
|
|
1
|
+
import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
4
4
|
import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -29,6 +29,26 @@ interface McpxCollectionOptions {
|
|
|
29
29
|
*/
|
|
30
30
|
allowLiveWrites?: boolean;
|
|
31
31
|
}
|
|
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
|
+
*/
|
|
38
|
+
interface McpxGlobalOptions {
|
|
39
|
+
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
40
|
+
read?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Expose `patchDocument` and `validateDocument`. Default `false`. Requires
|
|
43
|
+
* `versions.drafts` unless `allowLiveWrites` is set.
|
|
44
|
+
*/
|
|
45
|
+
write?: boolean;
|
|
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;
|
|
51
|
+
}
|
|
32
52
|
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
33
53
|
/**
|
|
34
54
|
* A custom tool. It is gated by its own checkbox on every API key and runs
|
|
@@ -63,6 +83,8 @@ interface McpxAuthResult {
|
|
|
63
83
|
type McpxPluginOptions = {
|
|
64
84
|
/** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
|
|
65
85
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
86
|
+
/** Allow-list of globals. `true` is shorthand for `{ read: true }`. */
|
|
87
|
+
globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
|
|
66
88
|
/** Collection the keys act as. Default `config.admin.user`, then `users`. */
|
|
67
89
|
userCollection?: CollectionSlug;
|
|
68
90
|
apiKeys?: {
|
|
@@ -103,6 +125,7 @@ interface McpxCollectionCapabilities {
|
|
|
103
125
|
*/
|
|
104
126
|
interface McpxResolvedCapabilities {
|
|
105
127
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
128
|
+
globals: Record<string, McpxCollectionCapabilities>;
|
|
106
129
|
tools: Record<string, boolean>;
|
|
107
130
|
}
|
|
108
131
|
interface McpxRequestContext {
|
|
@@ -110,4 +133,4 @@ interface McpxRequestContext {
|
|
|
110
133
|
capabilities: McpxResolvedCapabilities;
|
|
111
134
|
}
|
|
112
135
|
//#endregion
|
|
113
|
-
export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
|
|
136
|
+
export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
|
|
@@ -27,9 +27,7 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
27
27
|
* so it holds for every create and update on an MCP request, not only the
|
|
28
28
|
* builtin tools. Deletes are not guarded in v1; custom tools that delete are
|
|
29
29
|
* the integrator's responsibility.
|
|
30
|
-
*/ const
|
|
31
|
-
const { args, operation, req } = hookArgs;
|
|
32
|
-
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
30
|
+
*/ const scrubWriteArgs = (args) => {
|
|
33
31
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
34
32
|
if (next["data"] && typeof next["data"] === "object") {
|
|
35
33
|
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
@@ -41,18 +39,50 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
41
39
|
next["trash"] = false;
|
|
42
40
|
return next;
|
|
43
41
|
};
|
|
42
|
+
const forceDraftWrite = (hookArgs) => {
|
|
43
|
+
const { args, operation, req } = hookArgs;
|
|
44
|
+
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
45
|
+
return scrubWriteArgs(args);
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The global counterpart of {@link forceDraftWrite}. Payload invokes a global's
|
|
49
|
+
* `beforeOperation` with the whole argument bag and assigns the result back,
|
|
50
|
+
* exactly as the collection path does and before it reads `draft`,
|
|
51
|
+
* `publishAllLocales` or `data._status`, so the guard has the same reach here:
|
|
52
|
+
* every MCP write to a global, builtin tool or custom.
|
|
53
|
+
*
|
|
54
|
+
* The global operation union has no `create` member because a global always
|
|
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.
|
|
59
|
+
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
60
|
+
const { operation, req } = hookArgs;
|
|
61
|
+
const args = hookArgs.args;
|
|
62
|
+
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
63
|
+
return scrubWriteArgs(args);
|
|
64
|
+
};
|
|
44
65
|
/**
|
|
45
66
|
* Refuses an MCP write that would still not land as a draft. An alarm rather
|
|
46
67
|
* than the guarantee: `forceDraftWrite` should make it unreachable. It throws
|
|
47
68
|
* instead of correcting `_status` because Payload has already chosen the write
|
|
48
69
|
* branch by the time a `beforeChange` hook runs.
|
|
49
|
-
*/ const
|
|
50
|
-
if (!isMcpxRequest(req)) return
|
|
70
|
+
*/ const refuseUnlessDraft = (req, slug, data) => {
|
|
71
|
+
if (!isMcpxRequest(req)) return;
|
|
51
72
|
const status = data._status;
|
|
52
|
-
if (status === "draft") return
|
|
53
|
-
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${
|
|
73
|
+
if (status === "draft") return;
|
|
74
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a draft (_status: ${String(status)}).`);
|
|
54
75
|
throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
|
|
55
76
|
};
|
|
77
|
+
const refusePublish = ({ collection, data, req }) => {
|
|
78
|
+
refuseUnlessDraft(req, collection.slug, data);
|
|
79
|
+
return data;
|
|
80
|
+
};
|
|
81
|
+
/** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
|
|
82
|
+
const next = data;
|
|
83
|
+
refuseUnlessDraft(req, global.slug, next);
|
|
84
|
+
return next;
|
|
85
|
+
};
|
|
56
86
|
/**
|
|
57
87
|
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
58
88
|
* (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
|
|
@@ -66,5 +96,18 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
66
96
|
...hasDraftsEnabled(collection) ? { beforeChange: [...collection.hooks?.beforeChange ?? [], refusePublish] } : {}
|
|
67
97
|
}
|
|
68
98
|
}));
|
|
99
|
+
/**
|
|
100
|
+
* Attaches the guard to every global, exposed or not, for the same reason
|
|
101
|
+
* `installDraftGuards` covers every collection: a custom tool running on an MCP
|
|
102
|
+
* request must not be able to publish through a global the plugin config never
|
|
103
|
+
* mentioned.
|
|
104
|
+
*/ const installGlobalDraftGuards = (globals) => globals.map((global) => ({
|
|
105
|
+
...global,
|
|
106
|
+
hooks: {
|
|
107
|
+
...global.hooks,
|
|
108
|
+
beforeOperation: [...global.hooks?.beforeOperation ?? [], forceDraftWriteGlobal],
|
|
109
|
+
...hasDraftsEnabled(global) ? { beforeChange: [...global.hooks?.beforeChange ?? [], refusePublishGlobal] } : {}
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
69
112
|
//#endregion
|
|
70
|
-
export { forceDraftWrite, installDraftGuards, isMcpxRequest, refusePublish };
|
|
113
|
+
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, isMcpxRequest, refusePublish, refusePublishGlobal };
|
package/dist/write/patch.mjs
CHANGED
|
@@ -126,9 +126,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
126
126
|
for (const pointer of pointers) {
|
|
127
127
|
const resolution = resolveDataPointer(config, {
|
|
128
128
|
addedValue: value ?? moved,
|
|
129
|
-
collection: target.collection,
|
|
130
129
|
doc: target.doc,
|
|
131
|
-
pointer
|
|
130
|
+
pointer,
|
|
131
|
+
ref: target.ref
|
|
132
132
|
});
|
|
133
133
|
if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
|
|
134
134
|
pointer,
|
|
@@ -208,9 +208,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
208
208
|
/**
|
|
209
209
|
* The data handed to `payload.update` after a patch: the patched document
|
|
210
210
|
* reduced to the fields the client may write, plus row identity keys.
|
|
211
|
-
*/ const buildWriteData = (config,
|
|
211
|
+
*/ const buildWriteData = (config, target, doc) => {
|
|
212
212
|
return pickDescribed(config, doc, {
|
|
213
|
-
fields:
|
|
213
|
+
fields: target.flattenedFields,
|
|
214
214
|
prefix: [],
|
|
215
215
|
isRow: false
|
|
216
216
|
});
|
|
@@ -19,19 +19,20 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
19
19
|
* Limits: only the locale the doc was read in is checked, and field-level
|
|
20
20
|
* `beforeChange` hooks run again, which is safe only for pure ones.
|
|
21
21
|
*/ const collectPublishBlockers = async (req, target) => {
|
|
22
|
-
const {
|
|
22
|
+
const { doc, entity } = target;
|
|
23
23
|
const id = doc["id"];
|
|
24
24
|
const errors = [];
|
|
25
25
|
const data = {
|
|
26
26
|
...structuredClone(doc),
|
|
27
27
|
_status: "published"
|
|
28
28
|
};
|
|
29
|
+
const context = { ...req.context };
|
|
29
30
|
const shared = {
|
|
30
|
-
collection,
|
|
31
|
-
context
|
|
31
|
+
collection: entity.kind === "collection" ? entity.config : null,
|
|
32
|
+
context,
|
|
32
33
|
data,
|
|
33
34
|
doc,
|
|
34
|
-
global: null,
|
|
35
|
+
global: entity.kind === "global" ? entity.config : null,
|
|
35
36
|
operation: "update",
|
|
36
37
|
overrideAccess: true,
|
|
37
38
|
parentIndexPath: "",
|
|
@@ -45,7 +46,7 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
45
46
|
try {
|
|
46
47
|
await beforeValidateTraverseFields({
|
|
47
48
|
...shared,
|
|
48
|
-
fields:
|
|
49
|
+
fields: entity.config.fields,
|
|
49
50
|
siblingData: data
|
|
50
51
|
});
|
|
51
52
|
await beforeChangeTraverseFields({
|
|
@@ -53,14 +54,14 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
53
54
|
docWithLocales: doc,
|
|
54
55
|
errors,
|
|
55
56
|
fieldLabelPath: "",
|
|
56
|
-
fields:
|
|
57
|
+
fields: entity.config.fields,
|
|
57
58
|
mergeLocaleActions: [],
|
|
58
59
|
siblingData: data,
|
|
59
60
|
siblingDocWithLocales: doc,
|
|
60
61
|
skipValidation: false
|
|
61
62
|
});
|
|
62
63
|
} catch (error) {
|
|
63
|
-
req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${
|
|
64
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${entity.slug} draft: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
64
65
|
return [];
|
|
65
66
|
}
|
|
66
67
|
return errors.map((error) => ({
|
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": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.6",
|
|
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",
|