@abinnovision/payloadcms-mcpx 1.0.0-beta.3 → 1.0.0-beta.5
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 -24
- package/dist/api-keys/fields.mjs +27 -12
- package/dist/capabilities.mjs +16 -3
- package/dist/endpoint/handler.mjs +3 -1
- package/dist/endpoint/result.mjs +9 -3
- 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 +39 -1
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +3 -2
- package/dist/schema/describe.mjs +19 -25
- package/dist/schema/pointer.mjs +9 -12
- package/dist/schema/shape.mjs +9 -9
- package/dist/schema/walk.d.mts +1 -0
- package/dist/schema/walk.mjs +21 -12
- package/dist/tools/create-document.mjs +10 -9
- package/dist/tools/describe-schema.mjs +15 -9
- package/dist/tools/find-documents.mjs +4 -3
- package/dist/tools/get-document.mjs +26 -12
- package/dist/tools/list-capabilities.mjs +20 -2
- package/dist/tools/patch-document.mjs +35 -21
- 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/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +51 -8
- package/dist/write/patch.mjs +8 -9
- package/dist/write/publish-blockers.d.mts +2 -0
- package/dist/write/publish-blockers.mjs +10 -8
- package/package.json +1 -1
|
@@ -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 };
|
package/dist/version.mjs
CHANGED
|
@@ -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RESERVED_FIELD_NAMES, blockOf, describeFields, findBlocksField, splitPath } from "../schema/walk.mjs";
|
|
1
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeFields, findBlocksField, splitPath } from "../schema/walk.mjs";
|
|
2
2
|
import { validateWriteValue } from "../schema/shape.mjs";
|
|
3
3
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -6,8 +6,7 @@ import { Pointer, applyPatch } from "rfc6902";
|
|
|
6
6
|
//#region src/write/patch.ts
|
|
7
7
|
/**
|
|
8
8
|
* One RFC 6902 operation as accepted by `patchDocument`.
|
|
9
|
-
*/ const
|
|
10
|
-
const PATCH_OPERATION_SCHEMA = z.object({
|
|
9
|
+
*/ const PATCH_OPERATION_SCHEMA = z.object({
|
|
11
10
|
from: z.string().regex(JSON_POINTER_PATTERN).optional(),
|
|
12
11
|
op: z.enum([
|
|
13
12
|
"add",
|
|
@@ -127,9 +126,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
127
126
|
for (const pointer of pointers) {
|
|
128
127
|
const resolution = resolveDataPointer(config, {
|
|
129
128
|
addedValue: value ?? moved,
|
|
130
|
-
collection: target.collection,
|
|
131
129
|
doc: target.doc,
|
|
132
|
-
pointer
|
|
130
|
+
pointer,
|
|
131
|
+
ref: target.ref
|
|
133
132
|
});
|
|
134
133
|
if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
|
|
135
134
|
pointer,
|
|
@@ -186,13 +185,13 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
186
185
|
result[key] = entry;
|
|
187
186
|
continue;
|
|
188
187
|
}
|
|
189
|
-
if (candidates.some(({ parts }) => parts[1] === "
|
|
188
|
+
if (candidates.some(({ parts }) => parts[1] === "*")) {
|
|
190
189
|
result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
|
|
191
190
|
fields,
|
|
192
191
|
prefix: [
|
|
193
192
|
...prefix,
|
|
194
193
|
key,
|
|
195
|
-
"
|
|
194
|
+
"*"
|
|
196
195
|
],
|
|
197
196
|
isRow: true
|
|
198
197
|
}) : row) : entry;
|
|
@@ -209,9 +208,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
209
208
|
/**
|
|
210
209
|
* The data handed to `payload.update` after a patch: the patched document
|
|
211
210
|
* reduced to the fields the client may write, plus row identity keys.
|
|
212
|
-
*/ const buildWriteData = (config,
|
|
211
|
+
*/ const buildWriteData = (config, target, doc) => {
|
|
213
212
|
return pickDescribed(config, doc, {
|
|
214
|
-
fields:
|
|
213
|
+
fields: target.flattenedFields,
|
|
215
214
|
prefix: [],
|
|
216
215
|
isRow: false
|
|
217
216
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import "../tools/target.mjs";
|
|
1
2
|
import { PayloadRequest } from "payload";
|
|
2
3
|
//#region src/write/publish-blockers.d.ts
|
|
3
4
|
/**
|
|
@@ -7,6 +8,7 @@ interface PublishBlocker {
|
|
|
7
8
|
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
8
9
|
field?: string;
|
|
9
10
|
message: string;
|
|
11
|
+
/** JSON Pointer to the offending value, e.g. "/layout/2/title". */
|
|
10
12
|
path: string;
|
|
11
13
|
}
|
|
12
14
|
//#endregion
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pointerFromPayloadPath } from "../schema/walk.mjs";
|
|
1
2
|
import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payload";
|
|
2
3
|
//#region src/write/publish-blockers.ts
|
|
3
4
|
/**
|
|
@@ -18,19 +19,20 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
18
19
|
* Limits: only the locale the doc was read in is checked, and field-level
|
|
19
20
|
* `beforeChange` hooks run again, which is safe only for pure ones.
|
|
20
21
|
*/ const collectPublishBlockers = async (req, target) => {
|
|
21
|
-
const {
|
|
22
|
+
const { doc, entity } = target;
|
|
22
23
|
const id = doc["id"];
|
|
23
24
|
const errors = [];
|
|
24
25
|
const data = {
|
|
25
26
|
...structuredClone(doc),
|
|
26
27
|
_status: "published"
|
|
27
28
|
};
|
|
29
|
+
const context = { ...req.context };
|
|
28
30
|
const shared = {
|
|
29
|
-
collection,
|
|
30
|
-
context
|
|
31
|
+
collection: entity.kind === "collection" ? entity.config : null,
|
|
32
|
+
context,
|
|
31
33
|
data,
|
|
32
34
|
doc,
|
|
33
|
-
global: null,
|
|
35
|
+
global: entity.kind === "global" ? entity.config : null,
|
|
34
36
|
operation: "update",
|
|
35
37
|
overrideAccess: true,
|
|
36
38
|
parentIndexPath: "",
|
|
@@ -44,7 +46,7 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
44
46
|
try {
|
|
45
47
|
await beforeValidateTraverseFields({
|
|
46
48
|
...shared,
|
|
47
|
-
fields:
|
|
49
|
+
fields: entity.config.fields,
|
|
48
50
|
siblingData: data
|
|
49
51
|
});
|
|
50
52
|
await beforeChangeTraverseFields({
|
|
@@ -52,19 +54,19 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
52
54
|
docWithLocales: doc,
|
|
53
55
|
errors,
|
|
54
56
|
fieldLabelPath: "",
|
|
55
|
-
fields:
|
|
57
|
+
fields: entity.config.fields,
|
|
56
58
|
mergeLocaleActions: [],
|
|
57
59
|
siblingData: data,
|
|
58
60
|
siblingDocWithLocales: doc,
|
|
59
61
|
skipValidation: false
|
|
60
62
|
});
|
|
61
63
|
} catch (error) {
|
|
62
|
-
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"}`);
|
|
63
65
|
return [];
|
|
64
66
|
}
|
|
65
67
|
return errors.map((error) => ({
|
|
66
68
|
message: error.message,
|
|
67
|
-
path: error.path,
|
|
69
|
+
path: pointerFromPayloadPath(error.path),
|
|
68
70
|
...typeof error.label === "string" ? { field: error.label } : {}
|
|
69
71
|
}));
|
|
70
72
|
};
|
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.5",
|
|
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",
|