@abinnovision/payloadcms-mcpx 1.0.0-beta.11 → 1.0.0-beta.13
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 +103 -44
- package/dist/api-keys/fields.mjs +17 -4
- package/dist/capabilities.mjs +22 -3
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -21
- package/dist/endpoint/handler.mjs +4 -2
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +10 -5
- package/dist/index.d.mts +4 -5
- package/dist/index.mjs +2 -2
- package/dist/options.mjs +26 -9
- 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/{endpoint/result.d.mts → result.d.mts} +1 -2
- package/dist/result.mjs +22 -0
- package/dist/schema/index.mjs +6 -0
- package/dist/schema/pointer.mjs +1 -1
- package/dist/schema/walk.mjs +11 -15
- package/dist/tools/{index.mjs → builtin.mjs} +4 -2
- package/dist/tools/create-document.mjs +14 -9
- package/dist/tools/describe-schema.mjs +3 -3
- package/dist/tools/find-documents.mjs +1 -2
- package/dist/tools/get-document.mjs +2 -2
- package/dist/tools/list-capabilities.mjs +3 -2
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +14 -15
- package/dist/tools/publish-document.mjs +81 -0
- package/dist/tools/shared.mjs +50 -5
- package/dist/tools/target.mjs +4 -4
- package/dist/tools/validate-document.mjs +8 -9
- package/dist/types.d.mts +44 -23
- package/dist/write/draft-guard.mjs +71 -36
- package/dist/write/patch.mjs +131 -57
- package/dist/write/publish-blockers.mjs +10 -3
- package/dist/write/publish-intent.mjs +39 -0
- package/dist/write/transaction.mjs +7 -1
- package/package.json +1 -1
- package/dist/i18n.d.mts +0 -1
- 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/write/publish-blockers.d.mts +0 -15
package/dist/types.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
4
3
|
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
4
|
+
import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
//#region src/types.d.ts
|
|
6
6
|
declare module "payload" {
|
|
7
7
|
interface RequestContext {
|
|
@@ -11,6 +11,17 @@ declare module "payload" {
|
|
|
11
11
|
"@abinnovision/payloadcms-mcpx": McpxPluginOptions;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
type McpxWriteMode = "draft" | "live" | false;
|
|
14
25
|
/**
|
|
15
26
|
* What an exposed collection offers to MCP clients. A key can only enable
|
|
16
27
|
* what the config exposes here.
|
|
@@ -21,15 +32,10 @@ interface McpxCollectionOptions {
|
|
|
21
32
|
*/
|
|
22
33
|
read?: boolean;
|
|
23
34
|
/**
|
|
24
|
-
* Expose `patchDocument`, `createDocument` and `validateDocument
|
|
25
|
-
*
|
|
35
|
+
* Expose `patchDocument`, `createDocument` and `validateDocument`, and how
|
|
36
|
+
* far those writes reach. Default `false`.
|
|
26
37
|
*/
|
|
27
|
-
write?:
|
|
28
|
-
/**
|
|
29
|
-
* Permit writes to a collection without drafts. Such writes land on the live
|
|
30
|
-
* document because there is no draft to land on. Default `false`.
|
|
31
|
-
*/
|
|
32
|
-
allowLiveWrites?: boolean;
|
|
38
|
+
write?: McpxWriteMode;
|
|
33
39
|
}
|
|
34
40
|
/**
|
|
35
41
|
* What an exposed global offers to MCP clients. Structurally the same as
|
|
@@ -41,15 +47,10 @@ interface McpxGlobalOptions {
|
|
|
41
47
|
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
42
48
|
read?: boolean;
|
|
43
49
|
/**
|
|
44
|
-
* Expose `patchDocument` and `validateDocument
|
|
45
|
-
*
|
|
46
|
-
*/
|
|
47
|
-
write?: boolean;
|
|
48
|
-
/**
|
|
49
|
-
* Permit writes to a global without drafts. Such writes land on the live
|
|
50
|
-
* document because there is no draft to land on. Default `false`.
|
|
50
|
+
* Expose `patchDocument` and `validateDocument`, and how far those writes
|
|
51
|
+
* reach. Default `false`.
|
|
51
52
|
*/
|
|
52
|
-
|
|
53
|
+
write?: McpxWriteMode;
|
|
53
54
|
}
|
|
54
55
|
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
55
56
|
/**
|
|
@@ -59,8 +60,7 @@ type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
|
59
60
|
interface McpxExposedEntity {
|
|
60
61
|
slug: string;
|
|
61
62
|
read: boolean;
|
|
62
|
-
write:
|
|
63
|
-
allowLiveWrites: boolean;
|
|
63
|
+
write: McpxWriteMode;
|
|
64
64
|
hasDrafts: boolean;
|
|
65
65
|
/** Name of the capability group on the key document. */
|
|
66
66
|
fieldName: string;
|
|
@@ -72,12 +72,14 @@ interface McpxExposedEntity {
|
|
|
72
72
|
interface McpxToolScope {
|
|
73
73
|
req: PayloadRequest;
|
|
74
74
|
capabilities: McpxResolvedCapabilities;
|
|
75
|
-
/** Collection slugs the key may read / write. */
|
|
75
|
+
/** Collection slugs the key may read / write / publish. */
|
|
76
76
|
readable: string[];
|
|
77
77
|
writable: string[];
|
|
78
|
-
|
|
78
|
+
publishable: string[];
|
|
79
|
+
/** Global slugs the key may read / write / publish. */
|
|
79
80
|
readableGlobals: string[];
|
|
80
81
|
writableGlobals: string[];
|
|
82
|
+
publishableGlobals: string[];
|
|
81
83
|
/** Configured locale codes, or `null` when localization is off. */
|
|
82
84
|
locales: null | string[];
|
|
83
85
|
defaultLocale: null | string;
|
|
@@ -103,7 +105,11 @@ interface McpxToolScope {
|
|
|
103
105
|
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
104
106
|
/** camelCase, unique, not one of the builtin tool names. */
|
|
105
107
|
name: string;
|
|
106
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Fixed text, or text built per request so it can state what this key's
|
|
110
|
+
* writes actually do.
|
|
111
|
+
*/
|
|
112
|
+
description: string | ((scope: McpxToolScope) => string);
|
|
107
113
|
annotations?: ToolAnnotations;
|
|
108
114
|
/**
|
|
109
115
|
* Whether this key may call the tool; a tool that is not enabled never
|
|
@@ -210,6 +216,11 @@ type McpxPluginOptions = {
|
|
|
210
216
|
interface McpxCollectionCapabilities {
|
|
211
217
|
read: boolean;
|
|
212
218
|
write: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* Whether the key may publish this entity's draft. Only ever true where the
|
|
221
|
+
* config sets `write: "live"` and the entity has drafts.
|
|
222
|
+
*/
|
|
223
|
+
publish: boolean;
|
|
213
224
|
}
|
|
214
225
|
/**
|
|
215
226
|
* Capabilities in force for one request: plugin config AND key checkboxes.
|
|
@@ -223,5 +234,15 @@ interface McpxRequestContext {
|
|
|
223
234
|
apiKeyId: number | string;
|
|
224
235
|
capabilities: McpxResolvedCapabilities;
|
|
225
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* One reason a human could not publish the draft as it stands.
|
|
239
|
+
*/
|
|
240
|
+
interface PublishBlocker {
|
|
241
|
+
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
242
|
+
field?: string;
|
|
243
|
+
message: string;
|
|
244
|
+
/** JSON Pointer to the offending value, e.g. "/layout/2/title". */
|
|
245
|
+
path: string;
|
|
246
|
+
}
|
|
226
247
|
//#endregion
|
|
227
|
-
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, defineMcpxTool };
|
|
248
|
+
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool };
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { claimPublishIntent, isClaimedPublish } 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
6
|
/**
|
|
5
7
|
* Operation arguments that widen or redirect a write. Cleared on every MCP
|
|
6
|
-
* create and update so a tool cannot smuggle them in.
|
|
8
|
+
* create and update, publishes included, so a tool cannot smuggle them in.
|
|
7
9
|
*/ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
8
10
|
"where",
|
|
9
11
|
"publishAllLocales",
|
|
@@ -14,80 +16,113 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
14
16
|
"overwriteExistingFiles"
|
|
15
17
|
]);
|
|
16
18
|
/**
|
|
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.
|
|
19
|
+
* Forces every MCP write into a draft save, unless it is the one write
|
|
20
|
+
* `publishDocument` asked for.
|
|
23
21
|
*
|
|
24
22
|
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
25
23
|
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
26
24
|
* This runs as `beforeOperation`, before Payload reads any of these arguments,
|
|
27
25
|
* so it holds for every create and update on an MCP request, not only the
|
|
28
26
|
* builtin tools. Deletes are not guarded in v1; custom tools that delete are
|
|
29
|
-
* the integrator's responsibility.
|
|
30
|
-
|
|
27
|
+
* the integrator's responsibility. `restoreVersion` and `duplicate` are outside
|
|
28
|
+
* the operation filter too — `restoreVersion` is caught by `refusePublish`
|
|
29
|
+
* because it runs the collection's `beforeChange` hooks, and anything going
|
|
30
|
+
* straight to `payload.db` bypasses all of this.
|
|
31
|
+
*
|
|
32
|
+
* On a claimed publish the argument scrubbing is unchanged — the whole
|
|
33
|
+
* `STRIPPED_ARGS` list still goes, `deletedAt` still goes, autosave, locks and
|
|
34
|
+
* trash are still forced off. Only `draft` and `_status` differ. Writing
|
|
35
|
+
* `_status` here rather than in the tool keeps the tool honest: it asks to
|
|
36
|
+
* publish, and this is the only thing that can grant it.
|
|
37
|
+
*/ const scrubWriteArgs = (args, publishing) => {
|
|
31
38
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
32
39
|
if (next["data"] && typeof next["data"] === "object") {
|
|
33
40
|
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
34
|
-
next["data"] =
|
|
41
|
+
next["data"] = publishing ? {
|
|
42
|
+
...data,
|
|
43
|
+
_status: "published"
|
|
44
|
+
} : data;
|
|
35
45
|
}
|
|
36
|
-
next["draft"] =
|
|
46
|
+
next["draft"] = !publishing;
|
|
37
47
|
next["autosave"] = false;
|
|
38
48
|
next["overrideLock"] = false;
|
|
39
49
|
next["trash"] = false;
|
|
40
50
|
return next;
|
|
41
51
|
};
|
|
42
52
|
const forceDraftWrite = (hookArgs) => {
|
|
43
|
-
const { args, operation, req } = hookArgs;
|
|
53
|
+
const { args, collection, operation, req } = hookArgs;
|
|
44
54
|
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
45
|
-
|
|
55
|
+
const publishing = operation === "update" && claimPublishIntent({
|
|
56
|
+
kind: "collection",
|
|
57
|
+
slug: collection.slug,
|
|
58
|
+
id: args.id
|
|
59
|
+
});
|
|
60
|
+
return scrubWriteArgs(args, publishing);
|
|
46
61
|
};
|
|
47
62
|
/**
|
|
48
|
-
* The global counterpart of {@link forceDraftWrite}
|
|
49
|
-
* `
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
63
|
+
* The global counterpart of {@link forceDraftWrite}, with one important
|
|
64
|
+
* difference: Payload's `updateGlobal` destructures `draft`,
|
|
65
|
+
* `publishAllLocales`, `publishSpecificLocale`, `unpublishAllLocales` and
|
|
66
|
+
* `overrideLock` *before* it runs `beforeOperation`, and re-reads only `data`
|
|
67
|
+
* afterwards. Setting those here is a no-op. What still lands is `data`, and
|
|
68
|
+
* that is what the global draft guarantee actually rests on: `_status` is
|
|
69
|
+
* stripped, so a rogue `updateGlobal({ draft: false })` reaches
|
|
70
|
+
* {@link refusePublishGlobal} with no status and is refused there. The alarm,
|
|
71
|
+
* not the correction, is load-bearing for globals.
|
|
72
|
+
*
|
|
73
|
+
* The publish branch matters for the same reason. `publishDocument` passes
|
|
74
|
+
* `draft: false` at the call site because the hook cannot, and this hook must
|
|
75
|
+
* put `_status` back rather than strip it.
|
|
53
76
|
*
|
|
54
77
|
* The global operation union has no `create` member because a global always
|
|
55
|
-
* exists, so only `update` is intercepted.
|
|
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.
|
|
78
|
+
* exists, so only `update` is intercepted.
|
|
59
79
|
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
60
|
-
const { operation, req } = hookArgs;
|
|
80
|
+
const { global, operation, req } = hookArgs;
|
|
61
81
|
const args = hookArgs.args;
|
|
62
82
|
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
63
|
-
|
|
83
|
+
const publishing = claimPublishIntent({
|
|
84
|
+
kind: "global",
|
|
85
|
+
slug: global.slug
|
|
86
|
+
});
|
|
87
|
+
return scrubWriteArgs(args, publishing);
|
|
64
88
|
};
|
|
65
89
|
/**
|
|
66
|
-
* Refuses an MCP write that would still not land as a draft
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
90
|
+
* Refuses an MCP write that would still not land as a draft, and — on the one
|
|
91
|
+
* operation that claimed a publish intent — refuses anything that would not
|
|
92
|
+
* land as a publish. An alarm rather than the guarantee for collections, where
|
|
93
|
+
* `forceDraftWrite` should make it unreachable; the guarantee itself for
|
|
94
|
+
* globals, per {@link forceDraftWriteGlobal}. It throws instead of correcting
|
|
95
|
+
* `_status` because Payload has already chosen the write branch by the time a
|
|
96
|
+
* `beforeChange` hook runs.
|
|
97
|
+
*/ const refuseUnlessExpected = (req, target, data) => {
|
|
71
98
|
if (!isMcpxRequest(req)) return;
|
|
72
99
|
const status = data._status;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
const publishing = isClaimedPublish(target.kind, target.slug);
|
|
101
|
+
const expected = publishing ? "published" : "draft";
|
|
102
|
+
if (status === expected) return;
|
|
103
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${target.slug} that would not have been a ${expected} (_status: ${String(status)}).`);
|
|
104
|
+
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
105
|
};
|
|
77
106
|
const refusePublish = ({ collection, data, req }) => {
|
|
78
|
-
|
|
107
|
+
refuseUnlessExpected(req, {
|
|
108
|
+
kind: "collection",
|
|
109
|
+
slug: collection.slug
|
|
110
|
+
}, data);
|
|
79
111
|
return data;
|
|
80
112
|
};
|
|
81
113
|
/** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
|
|
82
114
|
const next = data;
|
|
83
|
-
|
|
115
|
+
refuseUnlessExpected(req, {
|
|
116
|
+
kind: "global",
|
|
117
|
+
slug: global.slug
|
|
118
|
+
}, next);
|
|
84
119
|
return next;
|
|
85
120
|
};
|
|
86
121
|
/**
|
|
87
122
|
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
88
123
|
* (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
|
|
89
124
|
* exist. Applied to the built collection list so nothing can join later
|
|
90
|
-
* without being covered.
|
|
125
|
+
* without being covered. Both are appended last, so a user hook cannot win.
|
|
91
126
|
*/ const installDraftGuards = (collections) => collections.map((collection) => ({
|
|
92
127
|
...collection,
|
|
93
128
|
hooks: {
|
|
@@ -110,4 +145,4 @@ const refusePublish = ({ collection, data, req }) => {
|
|
|
110
145
|
}
|
|
111
146
|
}));
|
|
112
147
|
//#endregion
|
|
113
|
-
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards,
|
|
148
|
+
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };
|
package/dist/write/patch.mjs
CHANGED
|
@@ -1,24 +1,45 @@
|
|
|
1
|
-
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, splitPath } from "../schema/walk.mjs";
|
|
2
|
-
import { validateWriteValue } from "../schema/shape.mjs";
|
|
1
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, joinPath, splitPath } from "../schema/walk.mjs";
|
|
3
2
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
3
|
+
import { validateWriteValue } from "../schema/shape.mjs";
|
|
4
|
+
import "../schema/index.mjs";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { Pointer, applyPatch } from "rfc6902";
|
|
6
7
|
//#region src/write/patch.ts
|
|
8
|
+
const POINTER = z.string().regex(JSON_POINTER_PATTERN);
|
|
7
9
|
/**
|
|
8
|
-
* One RFC 6902 operation as accepted by `patchDocument`.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"add",
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
10
|
+
* One RFC 6902 operation as accepted by `patchDocument`. Discriminated on `op`
|
|
11
|
+
* so an operation carries only the members RFC 6902 defines for it.
|
|
12
|
+
*/ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
|
|
13
|
+
z.strictObject({
|
|
14
|
+
op: z.literal("add"),
|
|
15
|
+
path: POINTER,
|
|
16
|
+
value: z.unknown()
|
|
17
|
+
}),
|
|
18
|
+
z.strictObject({
|
|
19
|
+
op: z.literal("remove"),
|
|
20
|
+
path: POINTER
|
|
21
|
+
}),
|
|
22
|
+
z.strictObject({
|
|
23
|
+
op: z.literal("replace"),
|
|
24
|
+
path: POINTER,
|
|
25
|
+
value: z.unknown()
|
|
26
|
+
}),
|
|
27
|
+
z.strictObject({
|
|
28
|
+
from: POINTER,
|
|
29
|
+
op: z.literal("move"),
|
|
30
|
+
path: POINTER
|
|
31
|
+
}),
|
|
32
|
+
z.strictObject({
|
|
33
|
+
from: POINTER,
|
|
34
|
+
op: z.literal("copy"),
|
|
35
|
+
path: POINTER
|
|
36
|
+
}),
|
|
37
|
+
z.strictObject({
|
|
38
|
+
op: z.literal("test"),
|
|
39
|
+
path: POINTER,
|
|
40
|
+
value: z.unknown()
|
|
41
|
+
})
|
|
42
|
+
]).describe("An RFC 6902 operation.");
|
|
22
43
|
/**
|
|
23
44
|
* Whether a pointer touches a field Payload maintains.
|
|
24
45
|
*/ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
|
|
@@ -87,59 +108,112 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
87
108
|
return next;
|
|
88
109
|
};
|
|
89
110
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return cloned;
|
|
104
|
-
});
|
|
105
|
-
const problems = applyPatch(next, prepared).flatMap((error, index) => error ? [`patches[${String(index)}]: ${error.message}`] : []);
|
|
106
|
-
if (problems.length > 0) return { problems };
|
|
107
|
-
reconcileRowIds(next, doc);
|
|
108
|
-
return { next };
|
|
111
|
+
* The operation as it is applied: values are cloned so the written document
|
|
112
|
+
* never shares references with the caller's operations, and a `replace` of a
|
|
113
|
+
* field the target locale has no value for becomes an `add`, which is what
|
|
114
|
+
* RFC 6902 requires when nothing is there to replace.
|
|
115
|
+
*/ const prepare = (operation, doc) => {
|
|
116
|
+
const cloned = "value" in operation ? {
|
|
117
|
+
...operation,
|
|
118
|
+
value: structuredClone(operation.value)
|
|
119
|
+
} : operation;
|
|
120
|
+
return cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(doc) === void 0 ? {
|
|
121
|
+
...cloned,
|
|
122
|
+
op: "add"
|
|
123
|
+
} : cloned;
|
|
109
124
|
};
|
|
110
125
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
* The value an operation writes at its path: the one it carries, or the one it
|
|
127
|
+
* takes from `from`. A `remove` writes nothing.
|
|
128
|
+
*/ const effectiveValue = (operation, doc) => {
|
|
129
|
+
if ("value" in operation) return operation.value;
|
|
130
|
+
return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Whether a resolved pointer lands in a read-only field. A pointer that stops
|
|
134
|
+
* short of one addresses a subtree, and the fields beneath it decide.
|
|
135
|
+
*/ const resolvesReadOnly = (resolution) => {
|
|
136
|
+
if (resolution.descriptor) return resolution.descriptor.readOnly === true;
|
|
137
|
+
const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
|
|
138
|
+
return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Whether the pointer addresses something read-only. An element carries no
|
|
142
|
+
* descriptor of its own, so the field it belongs to is read one segment up.
|
|
143
|
+
*/ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
|
|
144
|
+
doc: target.doc,
|
|
145
|
+
pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
|
|
146
|
+
ref: target.ref
|
|
147
|
+
}));
|
|
148
|
+
/**
|
|
149
|
+
* Checks one operation against the schema, in the state the document is in
|
|
150
|
+
* when that operation runs. Both pointers must resolve, whatever the operation
|
|
151
|
+
* writes at its path must pass write validation, and what it drops must not sit
|
|
152
|
+
* in a read-only field.
|
|
153
|
+
*/ const findOperationProblems = (config, target) => {
|
|
154
|
+
const { doc, operation, ref } = target;
|
|
155
|
+
const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
|
|
156
|
+
if (pointers.includes("")) return ["an empty pointer addresses the whole document. Address a field instead."];
|
|
119
157
|
const reserved = pointers.find(isReservedPointer);
|
|
120
|
-
if (reserved !== void 0) return [
|
|
158
|
+
if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. This tool only ever writes drafts, and id, _status, createdAt and updatedAt are not writable; use publishDocument to publish.`];
|
|
121
159
|
const dropped = droppedPointer(operation);
|
|
122
|
-
if (dropped !== void 0 && !isElementPointer(dropped)) return [
|
|
123
|
-
const value = "value" in operation ? operation.value : void 0;
|
|
160
|
+
if (dropped !== void 0 && !isElementPointer(dropped)) return [`"${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
|
|
124
161
|
try {
|
|
125
|
-
const
|
|
162
|
+
const value = effectiveValue(operation, doc);
|
|
163
|
+
if (value !== void 0 && operation.op !== "test" && isReadOnlyPointer(config, {
|
|
164
|
+
doc,
|
|
165
|
+
pointer: operation.path,
|
|
166
|
+
ref
|
|
167
|
+
})) return [`"${operation.path}" is read-only and cannot be written.`];
|
|
168
|
+
if (dropped !== void 0 && isReadOnlyPointer(config, {
|
|
169
|
+
doc,
|
|
170
|
+
pointer: dropped,
|
|
171
|
+
ref
|
|
172
|
+
})) return [`"${dropped}" sits in a read-only field and cannot be removed.`];
|
|
126
173
|
for (const pointer of pointers) {
|
|
127
174
|
const resolution = resolveDataPointer(config, {
|
|
128
|
-
addedValue: value
|
|
129
|
-
doc
|
|
175
|
+
addedValue: value,
|
|
176
|
+
doc,
|
|
130
177
|
pointer,
|
|
131
|
-
ref
|
|
178
|
+
ref
|
|
132
179
|
});
|
|
133
|
-
if (pointer === operation.path && value !== void 0)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
180
|
+
if (pointer === operation.path && value !== void 0) {
|
|
181
|
+
const problems = validateWriteValue(config, {
|
|
182
|
+
pointer,
|
|
183
|
+
resolution
|
|
184
|
+
}, value);
|
|
185
|
+
if (problems.length > 0) return problems;
|
|
186
|
+
}
|
|
137
187
|
}
|
|
138
188
|
return [];
|
|
139
189
|
} catch (error) {
|
|
140
|
-
return [
|
|
190
|
+
return [error instanceof Error ? error.message : "invalid"];
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Validates and applies every operation against one evolving copy of the
|
|
195
|
+
* document, so an operation that depends on an earlier one resolves against
|
|
196
|
+
* the shape it actually modifies.
|
|
197
|
+
*
|
|
198
|
+
* The copy means a failing operation leaves the original untouched, and the
|
|
199
|
+
* caller writes nothing unless the whole batch came back applied, so a
|
|
200
|
+
* partially applied batch is never persisted.
|
|
201
|
+
*/ const applyPatchOperations = (config, target) => {
|
|
202
|
+
const next = structuredClone(target.doc);
|
|
203
|
+
for (const [index, operation] of target.patches.entries()) {
|
|
204
|
+
const at = `patches[${String(index)}]`;
|
|
205
|
+
const problems = findOperationProblems(config, {
|
|
206
|
+
doc: next,
|
|
207
|
+
operation,
|
|
208
|
+
ref: target.ref
|
|
209
|
+
});
|
|
210
|
+
if (problems.length > 0) return { problems: problems.map((problem) => `${at}: ${problem}`) };
|
|
211
|
+
const [error] = applyPatch(next, [prepare(operation, next)]);
|
|
212
|
+
if (error) return { problems: [`${at}: ${error.message}`] };
|
|
141
213
|
}
|
|
142
|
-
|
|
214
|
+
reconcileRowIds(next, target.doc);
|
|
215
|
+
return { next };
|
|
216
|
+
};
|
|
143
217
|
/**
|
|
144
218
|
* Keys Payload manages on a row that travel back into the write unchanged.
|
|
145
219
|
*/ const ROW_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -216,4 +290,4 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
216
290
|
});
|
|
217
291
|
};
|
|
218
292
|
//#endregion
|
|
219
|
-
export { PATCH_OPERATION_SCHEMA,
|
|
293
|
+
export { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, droppedPointer, isElementPointer, isReservedPointer, stripRowIds };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pointerFromPayloadPath } from "../schema/walk.mjs";
|
|
2
|
+
import "../schema/index.mjs";
|
|
2
3
|
import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payload";
|
|
3
4
|
//#region src/write/publish-blockers.ts
|
|
4
5
|
/**
|
|
@@ -18,6 +19,9 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
18
19
|
*
|
|
19
20
|
* Limits: only the locale the doc was read in is checked, and field-level
|
|
20
21
|
* `beforeChange` hooks run again, which is safe only for pure ones.
|
|
22
|
+
*
|
|
23
|
+
* `unavailable` marks a traversal that threw, which is not the same answer as
|
|
24
|
+
* a document with nothing wrong with it.
|
|
21
25
|
*/ const collectPublishBlockers = async (req, target) => {
|
|
22
26
|
const { doc, entity } = target;
|
|
23
27
|
const id = doc["id"];
|
|
@@ -62,13 +66,16 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
62
66
|
});
|
|
63
67
|
} catch (error) {
|
|
64
68
|
req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${entity.slug} draft: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
65
|
-
return
|
|
69
|
+
return {
|
|
70
|
+
blockers: [],
|
|
71
|
+
unavailable: true
|
|
72
|
+
};
|
|
66
73
|
}
|
|
67
|
-
return errors.map((error) => ({
|
|
74
|
+
return { blockers: errors.map((error) => ({
|
|
68
75
|
message: error.message,
|
|
69
76
|
path: pointerFromPayloadPath(error.path),
|
|
70
77
|
...typeof error.label === "string" ? { field: error.label } : {}
|
|
71
|
-
}));
|
|
78
|
+
})) };
|
|
72
79
|
};
|
|
73
80
|
//#endregion
|
|
74
81
|
export { collectPublishBlockers };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
//#region src/write/publish-intent.ts
|
|
3
|
+
const store = new AsyncLocalStorage();
|
|
4
|
+
/** Runs `fn` with `intent` in force. */ const withPublishIntent = async (intent, fn) => await store.run({
|
|
5
|
+
...intent,
|
|
6
|
+
claimed: false
|
|
7
|
+
}, fn);
|
|
8
|
+
const activeFor = (target) => {
|
|
9
|
+
const active = store.getStore();
|
|
10
|
+
return active && active.kind === target.kind && active.slug === target.slug && active.id === target.id ? active : void 0;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Claims the intent for one operation, which `beforeOperation` does so that a
|
|
14
|
+
* re-entrant write to the same document — an `afterChange` hook calling
|
|
15
|
+
* `payload.update`, say — cannot ride along on it. Only the first operation to
|
|
16
|
+
* ask gets it.
|
|
17
|
+
*/ const claimPublishIntent = (target) => {
|
|
18
|
+
const active = activeFor(target);
|
|
19
|
+
if (!active || active.claimed) return false;
|
|
20
|
+
active.claimed = true;
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Whether this change belongs to the operation that claimed the intent, which
|
|
25
|
+
* is what lets the `beforeChange` alarm accept a published status.
|
|
26
|
+
*
|
|
27
|
+
* The id is deliberately not compared here. A `beforeChange` hook reads it from
|
|
28
|
+
* the loaded document, where Payload has already coerced it to the collection's
|
|
29
|
+
* id type, while the claim above saw the raw tool argument; comparing the two
|
|
30
|
+
* would refuse a legitimate publish over `1` versus `"1"`. Nothing is lost: a
|
|
31
|
+
* nested write to another document of the same collection during the publish
|
|
32
|
+
* cannot claim the intent, so `forceDraftWrite` has already stripped its
|
|
33
|
+
* `_status` and it fails the alarm on that.
|
|
34
|
+
*/ const isClaimedPublish = (kind, slug) => {
|
|
35
|
+
const active = store.getStore();
|
|
36
|
+
return active?.kind === kind && active.slug === slug && active.claimed;
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { claimPublishIntent, isClaimedPublish, withPublishIntent };
|
|
@@ -2,8 +2,14 @@ import { commitTransaction, initTransaction, killTransaction } from "payload";
|
|
|
2
2
|
//#region src/write/transaction.ts
|
|
3
3
|
/**
|
|
4
4
|
* Runs `fn` inside one database transaction on `req`, so a read followed by a
|
|
5
|
-
* write
|
|
5
|
+
* write is committed or rolled back together. Adapters without transaction
|
|
6
6
|
* support, or a request that already owns one, run `fn` as is.
|
|
7
|
+
*
|
|
8
|
+
* Atomicity, not isolation: neither SQLite nor Postgres at read committed locks
|
|
9
|
+
* the row on the read, so an `expectedUpdatedAt` check remains best effort. Nor
|
|
10
|
+
* is this safe across the tool calls of one JSON-RPC batch, which share a
|
|
11
|
+
* request: the second caller joins the first's transaction, so one tool's
|
|
12
|
+
* rollback takes the other's work with it.
|
|
7
13
|
*/ const withTransaction = async (req, fn) => {
|
|
8
14
|
if (!await initTransaction(req)) return await fn();
|
|
9
15
|
try {
|
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.13",
|
|
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",
|
package/dist/i18n.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import { PayloadRequest } from "payload";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import "payload";
|
package/dist/schema/walk.d.mts
DELETED
package/dist/tools/target.d.mts
DELETED