@abinnovision/payloadcms-mcpx 1.0.0-beta.10
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/LICENSE +201 -0
- package/README.md +353 -0
- package/dist/api-keys/collection.mjs +58 -0
- package/dist/api-keys/fields.mjs +137 -0
- package/dist/api-keys/key.mjs +10 -0
- package/dist/api-keys/setup-guide.mjs +54 -0
- package/dist/auth/resolve.mjs +62 -0
- package/dist/capabilities.mjs +43 -0
- 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/handler.mjs +86 -0
- package/dist/endpoint/result.mjs +65 -0
- package/dist/endpoint/server.mjs +52 -0
- package/dist/i18n.d.mts +1 -0
- package/dist/i18n.mjs +40 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +4 -0
- package/dist/options.d.mts +2 -0
- package/dist/options.mjs +146 -0
- package/dist/plugin.d.mts +9 -0
- package/dist/plugin.mjs +43 -0
- package/dist/schema/describe.mjs +160 -0
- package/dist/schema/lexical.d.mts +1 -0
- package/dist/schema/lexical.mjs +117 -0
- package/dist/schema/pointer.mjs +80 -0
- package/dist/schema/shape.mjs +208 -0
- package/dist/schema/walk.d.mts +3 -0
- package/dist/schema/walk.mjs +165 -0
- package/dist/tools/create-document.mjs +68 -0
- package/dist/tools/describe-schema.mjs +54 -0
- package/dist/tools/find-documents.mjs +55 -0
- package/dist/tools/get-document.mjs +68 -0
- package/dist/tools/index.mjs +23 -0
- package/dist/tools/list-capabilities.mjs +68 -0
- package/dist/tools/names.mjs +14 -0
- package/dist/tools/patch-document.mjs +127 -0
- package/dist/tools/shared.mjs +97 -0
- 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 +55 -0
- package/dist/types.d.mts +143 -0
- package/dist/types.mjs +7 -0
- package/dist/version.mjs +6 -0
- package/dist/write/draft-guard.d.mts +10 -0
- package/dist/write/draft-guard.mjs +113 -0
- package/dist/write/patch.mjs +219 -0
- package/dist/write/publish-blockers.d.mts +15 -0
- package/dist/write/publish-blockers.mjs +74 -0
- package/dist/write/transaction.mjs +19 -0
- package/package.json +104 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { APIError } from "payload";
|
|
2
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
3
|
+
//#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([
|
|
8
|
+
"where",
|
|
9
|
+
"publishAllLocales",
|
|
10
|
+
"publishSpecificLocale",
|
|
11
|
+
"unpublishAllLocales",
|
|
12
|
+
"duplicateFromID",
|
|
13
|
+
"selectedLocales",
|
|
14
|
+
"overwriteExistingFiles"
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* Whether a request originated from the MCP endpoint. The endpoint stamps
|
|
18
|
+
* `req.context.mcpx`, which travels into every local API call made with the
|
|
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.
|
|
23
|
+
*
|
|
24
|
+
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
25
|
+
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
26
|
+
* This runs as `beforeOperation`, before Payload reads any of these arguments,
|
|
27
|
+
* so it holds for every create and update on an MCP request, not only the
|
|
28
|
+
* builtin tools. Deletes are not guarded in v1; custom tools that delete are
|
|
29
|
+
* the integrator's responsibility.
|
|
30
|
+
*/ const scrubWriteArgs = (args) => {
|
|
31
|
+
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
32
|
+
if (next["data"] && typeof next["data"] === "object") {
|
|
33
|
+
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
34
|
+
next["data"] = data;
|
|
35
|
+
}
|
|
36
|
+
next["draft"] = true;
|
|
37
|
+
next["autosave"] = false;
|
|
38
|
+
next["overrideLock"] = false;
|
|
39
|
+
next["trash"] = false;
|
|
40
|
+
return next;
|
|
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
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Refuses an MCP write that would still not land as a draft. An alarm rather
|
|
67
|
+
* than the guarantee: `forceDraftWrite` should make it unreachable. It throws
|
|
68
|
+
* instead of correcting `_status` because Payload has already chosen the write
|
|
69
|
+
* branch by the time a `beforeChange` hook runs.
|
|
70
|
+
*/ const refuseUnlessDraft = (req, slug, data) => {
|
|
71
|
+
if (!isMcpxRequest(req)) return;
|
|
72
|
+
const status = data._status;
|
|
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)}).`);
|
|
75
|
+
throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
|
|
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
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
88
|
+
* (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
|
|
89
|
+
* exist. Applied to the built collection list so nothing can join later
|
|
90
|
+
* without being covered.
|
|
91
|
+
*/ const installDraftGuards = (collections) => collections.map((collection) => ({
|
|
92
|
+
...collection,
|
|
93
|
+
hooks: {
|
|
94
|
+
...collection.hooks,
|
|
95
|
+
beforeOperation: [...collection.hooks?.beforeOperation ?? [], forceDraftWrite],
|
|
96
|
+
...hasDraftsEnabled(collection) ? { beforeChange: [...collection.hooks?.beforeChange ?? [], refusePublish] } : {}
|
|
97
|
+
}
|
|
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
|
+
}));
|
|
112
|
+
//#endregion
|
|
113
|
+
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, isMcpxRequest, refusePublish, refusePublishGlobal };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, splitPath } from "../schema/walk.mjs";
|
|
2
|
+
import { validateWriteValue } from "../schema/shape.mjs";
|
|
3
|
+
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { Pointer, applyPatch } from "rfc6902";
|
|
6
|
+
//#region src/write/patch.ts
|
|
7
|
+
/**
|
|
8
|
+
* One RFC 6902 operation as accepted by `patchDocument`.
|
|
9
|
+
*/ const PATCH_OPERATION_SCHEMA = z.object({
|
|
10
|
+
from: z.string().regex(JSON_POINTER_PATTERN).optional(),
|
|
11
|
+
op: z.enum([
|
|
12
|
+
"add",
|
|
13
|
+
"copy",
|
|
14
|
+
"move",
|
|
15
|
+
"remove",
|
|
16
|
+
"replace",
|
|
17
|
+
"test"
|
|
18
|
+
]),
|
|
19
|
+
path: z.string().regex(JSON_POINTER_PATTERN),
|
|
20
|
+
value: z.unknown().optional()
|
|
21
|
+
}).describe("An RFC 6902 operation.");
|
|
22
|
+
/**
|
|
23
|
+
* Whether a pointer touches a field Payload maintains.
|
|
24
|
+
*/ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
|
|
25
|
+
/**
|
|
26
|
+
* The pointer an operation removes a value from, if it removes one at all.
|
|
27
|
+
*/ const droppedPointer = (operation) => {
|
|
28
|
+
if (operation.op === "remove") return operation.path;
|
|
29
|
+
return operation.op === "move" ? operation.from : void 0;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Whether a pointer addresses a list element rather than a field.
|
|
33
|
+
*/ const isElementPointer = (pointer) => {
|
|
34
|
+
const last = pointer.split("/").pop() ?? "";
|
|
35
|
+
return last === "-" || /^\d+$/.test(last);
|
|
36
|
+
};
|
|
37
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
/**
|
|
39
|
+
* A Lexical editor state. Its nodes manage their own ids, so it is never
|
|
40
|
+
* descended into.
|
|
41
|
+
*/ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
|
|
42
|
+
/**
|
|
43
|
+
* Visits every row in a value: a plain object that carries `blockType` or
|
|
44
|
+
* sits directly inside an array. Rich text states manage their own nodes and
|
|
45
|
+
* are never descended into.
|
|
46
|
+
*/ const walkRows = (value, visit, isRow = false) => {
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
for (const entry of value) walkRows(entry, visit, true);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!isPlainObject(value) || isRichTextState(value)) return;
|
|
52
|
+
if (isRow || typeof value["blockType"] === "string") visit(value);
|
|
53
|
+
for (const entry of Object.values(value)) walkRows(entry, visit);
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Keeps a row id only when the stored document already has it and no earlier
|
|
57
|
+
* row in the write claimed it; every other id is dropped so Payload assigns a
|
|
58
|
+
* fresh one.
|
|
59
|
+
*
|
|
60
|
+
* A kept id makes Payload update the row in place, which preserves the other
|
|
61
|
+
* locales of any localized field inside it. A duplicated id (a copied row) or
|
|
62
|
+
* an id from elsewhere would violate a SQL primary key, so those never pass.
|
|
63
|
+
*/ const reconcileRowIds = (next, stored) => {
|
|
64
|
+
const known = /* @__PURE__ */ new Set();
|
|
65
|
+
walkRows(stored, (row) => {
|
|
66
|
+
if (row["id"] !== void 0) known.add(row["id"]);
|
|
67
|
+
});
|
|
68
|
+
const seen = /* @__PURE__ */ new Set();
|
|
69
|
+
walkRows(next, (row) => {
|
|
70
|
+
const id = row["id"];
|
|
71
|
+
if (id === void 0) return;
|
|
72
|
+
if (known.has(id) && !seen.has(id)) {
|
|
73
|
+
seen.add(id);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
delete row["id"];
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Drops every row id from a copy of `value`. Used on create, where no stored
|
|
81
|
+
* row exists and any incoming id is client-invented.
|
|
82
|
+
*/ const stripRowIds = (value) => {
|
|
83
|
+
const next = structuredClone(value);
|
|
84
|
+
walkRows(next, (row) => {
|
|
85
|
+
delete row["id"];
|
|
86
|
+
});
|
|
87
|
+
return next;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Applies a patch to a deep copy of the document, so a failing operation
|
|
91
|
+
* leaves the original untouched and nothing partial is ever written.
|
|
92
|
+
*/ const applyPatchToCopy = (doc, patches) => {
|
|
93
|
+
const next = structuredClone(doc);
|
|
94
|
+
const prepared = patches.map((operation) => {
|
|
95
|
+
const cloned = "value" in operation ? {
|
|
96
|
+
...operation,
|
|
97
|
+
value: structuredClone(operation.value)
|
|
98
|
+
} : operation;
|
|
99
|
+
if (cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(next) === void 0) return {
|
|
100
|
+
...cloned,
|
|
101
|
+
op: "add"
|
|
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 };
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Checks every operation against the schema before any is applied.
|
|
112
|
+
*
|
|
113
|
+
* A partially applied batch is worse than a refused one, so this returns all
|
|
114
|
+
* problems and the caller applies nothing unless the list is empty.
|
|
115
|
+
*/ const findPatchProblems = (config, target) => target.patches.flatMap((operation, index) => {
|
|
116
|
+
const at = `patches[${String(index)}]`;
|
|
117
|
+
const pointers = [operation.path, ..."from" in operation && operation.from ? [operation.from] : []];
|
|
118
|
+
if (pointers.includes("")) return [`${at}: an empty pointer addresses the whole document. Address a field instead.`];
|
|
119
|
+
const reserved = pointers.find(isReservedPointer);
|
|
120
|
+
if (reserved !== void 0) return [`${at}: "${reserved}" addresses a field Payload maintains. Drafts are the only thing this tool writes, and id, _status, createdAt and updatedAt are not writable.`];
|
|
121
|
+
const dropped = droppedPointer(operation);
|
|
122
|
+
if (dropped !== void 0 && !isElementPointer(dropped)) return [`${at}: "${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.`];
|
|
123
|
+
const value = "value" in operation ? operation.value : void 0;
|
|
124
|
+
try {
|
|
125
|
+
const moved = "from" in operation && operation.from ? Pointer.fromJSON(operation.from).get(target.doc) : void 0;
|
|
126
|
+
for (const pointer of pointers) {
|
|
127
|
+
const resolution = resolveDataPointer(config, {
|
|
128
|
+
addedValue: value ?? moved,
|
|
129
|
+
doc: target.doc,
|
|
130
|
+
pointer,
|
|
131
|
+
ref: target.ref
|
|
132
|
+
});
|
|
133
|
+
if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
|
|
134
|
+
pointer,
|
|
135
|
+
resolution
|
|
136
|
+
}, value).map((problem) => `${at}: ${problem}`);
|
|
137
|
+
}
|
|
138
|
+
return [];
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return [`${at}: ${error instanceof Error ? error.message : "invalid"}`];
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
/**
|
|
144
|
+
* Keys Payload manages on a row that travel back into the write unchanged.
|
|
145
|
+
*/ const ROW_KEYS = /* @__PURE__ */ new Set([
|
|
146
|
+
"blockName",
|
|
147
|
+
"blockType",
|
|
148
|
+
"id"
|
|
149
|
+
]);
|
|
150
|
+
/**
|
|
151
|
+
* Picks the keys the schema walker describes out of `value`, descending into
|
|
152
|
+
* groups, named tabs, arrays and blocks. Everything Payload maintains or
|
|
153
|
+
* derives (`_status`, timestamps, join and virtual fields, upload base fields)
|
|
154
|
+
* is left out, so the write-back carries only what a client could have set.
|
|
155
|
+
*/ const pickDescribed = (config, value, at) => {
|
|
156
|
+
const { fields, prefix, isRow } = at;
|
|
157
|
+
const relative = describeAddressableFields(fields).flatMap((descriptor) => {
|
|
158
|
+
const parts = splitPath(descriptor.path);
|
|
159
|
+
return prefix.every((part, offset) => part === parts[offset]) ? [{
|
|
160
|
+
descriptor,
|
|
161
|
+
parts: parts.slice(prefix.length)
|
|
162
|
+
}] : [];
|
|
163
|
+
});
|
|
164
|
+
const result = {};
|
|
165
|
+
if (isRow) {
|
|
166
|
+
for (const key of ROW_KEYS) if (key in value) result[key] = value[key];
|
|
167
|
+
}
|
|
168
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
169
|
+
const candidates = relative.filter(({ parts }) => parts[0] === key);
|
|
170
|
+
if (candidates.length === 0) continue;
|
|
171
|
+
const exact = candidates.find(({ parts }) => parts.length === 1);
|
|
172
|
+
if (exact?.descriptor.type === "blocks" && Array.isArray(entry)) {
|
|
173
|
+
const field = findBlocksField(fields, splitPath(exact.descriptor.path));
|
|
174
|
+
result[key] = entry.map((row) => {
|
|
175
|
+
const block = field && isPlainObject(row) && typeof row["blockType"] === "string" ? blockOf(config, field, row["blockType"]) : void 0;
|
|
176
|
+
return block && isPlainObject(row) ? pickDescribed(config, row, {
|
|
177
|
+
fields: block.flattenedFields,
|
|
178
|
+
prefix: [],
|
|
179
|
+
isRow: true
|
|
180
|
+
}) : row;
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (exact) {
|
|
185
|
+
result[key] = entry;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (candidates.some(({ parts }) => parts[1] === "*")) {
|
|
189
|
+
result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
|
|
190
|
+
fields,
|
|
191
|
+
prefix: [
|
|
192
|
+
...prefix,
|
|
193
|
+
key,
|
|
194
|
+
"*"
|
|
195
|
+
],
|
|
196
|
+
isRow: true
|
|
197
|
+
}) : row) : entry;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
result[key] = isPlainObject(entry) ? pickDescribed(config, entry, {
|
|
201
|
+
fields,
|
|
202
|
+
prefix: [...prefix, key],
|
|
203
|
+
isRow: false
|
|
204
|
+
}) : entry;
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* The data handed to `payload.update` after a patch: the patched document
|
|
210
|
+
* reduced to the fields the client may write, plus row identity keys.
|
|
211
|
+
*/ const buildWriteData = (config, target, doc) => {
|
|
212
|
+
return pickDescribed(config, doc, {
|
|
213
|
+
fields: target.flattenedFields,
|
|
214
|
+
prefix: [],
|
|
215
|
+
isRow: false
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
//#endregion
|
|
219
|
+
export { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, droppedPointer, findPatchProblems, isElementPointer, isReservedPointer, stripRowIds };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "../tools/target.mjs";
|
|
2
|
+
import { PayloadRequest } from "payload";
|
|
3
|
+
//#region src/write/publish-blockers.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* One reason a human could not publish the draft as it stands.
|
|
6
|
+
*/
|
|
7
|
+
interface PublishBlocker {
|
|
8
|
+
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
9
|
+
field?: string;
|
|
10
|
+
message: string;
|
|
11
|
+
/** JSON Pointer to the offending value, e.g. "/layout/2/title". */
|
|
12
|
+
path: string;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export type { PublishBlocker };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { pointerFromPayloadPath } from "../schema/walk.mjs";
|
|
2
|
+
import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payload";
|
|
3
|
+
//#region src/write/publish-blockers.ts
|
|
4
|
+
/**
|
|
5
|
+
* Runs Payload's own field validation over a draft without saving anything.
|
|
6
|
+
*
|
|
7
|
+
* Draft saves skip validation unless `versions.drafts.validate` is set, so an
|
|
8
|
+
* agent building a document incrementally gets no signal until a human presses
|
|
9
|
+
* Publish. This is the same traversal a real save runs, exported from
|
|
10
|
+
* `payload`, called with `skipValidation: false` so it collects into `errors`
|
|
11
|
+
* instead of throwing. The `beforeValidate` pass runs first because some field
|
|
12
|
+
* hooks (Lexical's) prepare state in `context` that their `beforeChange`
|
|
13
|
+
* counterpart depends on.
|
|
14
|
+
*
|
|
15
|
+
* Nothing is written: `data` is a copy, the context is a scratch copy and the
|
|
16
|
+
* locale merge actions are discarded. `overrideAccess` is true because the
|
|
17
|
+
* question is "could this be published", not "may this client write it".
|
|
18
|
+
*
|
|
19
|
+
* Limits: only the locale the doc was read in is checked, and field-level
|
|
20
|
+
* `beforeChange` hooks run again, which is safe only for pure ones.
|
|
21
|
+
*/ const collectPublishBlockers = async (req, target) => {
|
|
22
|
+
const { doc, entity } = target;
|
|
23
|
+
const id = doc["id"];
|
|
24
|
+
const errors = [];
|
|
25
|
+
const data = {
|
|
26
|
+
...structuredClone(doc),
|
|
27
|
+
_status: "published"
|
|
28
|
+
};
|
|
29
|
+
const context = { ...req.context };
|
|
30
|
+
const shared = {
|
|
31
|
+
collection: entity.kind === "collection" ? entity.config : null,
|
|
32
|
+
context,
|
|
33
|
+
data,
|
|
34
|
+
doc,
|
|
35
|
+
global: entity.kind === "global" ? entity.config : null,
|
|
36
|
+
operation: "update",
|
|
37
|
+
overrideAccess: true,
|
|
38
|
+
parentIndexPath: "",
|
|
39
|
+
parentIsLocalized: false,
|
|
40
|
+
parentPath: "",
|
|
41
|
+
parentSchemaPath: "",
|
|
42
|
+
req,
|
|
43
|
+
siblingDoc: doc,
|
|
44
|
+
...id === void 0 ? {} : { id }
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
await beforeValidateTraverseFields({
|
|
48
|
+
...shared,
|
|
49
|
+
fields: entity.config.fields,
|
|
50
|
+
siblingData: data
|
|
51
|
+
});
|
|
52
|
+
await beforeChangeTraverseFields({
|
|
53
|
+
...shared,
|
|
54
|
+
docWithLocales: doc,
|
|
55
|
+
errors,
|
|
56
|
+
fieldLabelPath: "",
|
|
57
|
+
fields: entity.config.fields,
|
|
58
|
+
mergeLocaleActions: [],
|
|
59
|
+
siblingData: data,
|
|
60
|
+
siblingDocWithLocales: doc,
|
|
61
|
+
skipValidation: false
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${entity.slug} draft: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return errors.map((error) => ({
|
|
68
|
+
message: error.message,
|
|
69
|
+
path: pointerFromPayloadPath(error.path),
|
|
70
|
+
...typeof error.label === "string" ? { field: error.label } : {}
|
|
71
|
+
}));
|
|
72
|
+
};
|
|
73
|
+
//#endregion
|
|
74
|
+
export { collectPublishBlockers };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { commitTransaction, initTransaction, killTransaction } from "payload";
|
|
2
|
+
//#region src/write/transaction.ts
|
|
3
|
+
/**
|
|
4
|
+
* Runs `fn` inside one database transaction on `req`, so a read followed by a
|
|
5
|
+
* write cannot interleave with another writer. Adapters without transaction
|
|
6
|
+
* support, or a request that already owns one, run `fn` as is.
|
|
7
|
+
*/ const withTransaction = async (req, fn) => {
|
|
8
|
+
if (!await initTransaction(req)) return await fn();
|
|
9
|
+
try {
|
|
10
|
+
const result = await fn();
|
|
11
|
+
await commitTransaction(req);
|
|
12
|
+
return result;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
await killTransaction(req);
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { withTransaction };
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@abinnovision/payloadcms-mcpx",
|
|
4
|
+
"version": "1.0.0-beta.10",
|
|
5
|
+
"description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"payload",
|
|
8
|
+
"payloadcms",
|
|
9
|
+
"plugin",
|
|
10
|
+
"mcp",
|
|
11
|
+
"model-context-protocol"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/abinnovision/payloadcms-commons.git",
|
|
16
|
+
"directory": "packages/mcpx"
|
|
17
|
+
},
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"author": {
|
|
20
|
+
"name": "abi group GmbH",
|
|
21
|
+
"email": "info@abigroup.io",
|
|
22
|
+
"url": "https://abigroup.io"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.mts",
|
|
28
|
+
"default": "./dist/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./client": {
|
|
31
|
+
"types": "./dist/client/index.d.mts",
|
|
32
|
+
"default": "./dist/client/index.mjs"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsdown",
|
|
42
|
+
"format:check": "prettier --check 'src/**/*.{ts,tsx}' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
|
|
43
|
+
"format:fix": "prettier --write 'src/**/*.{ts,tsx}' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
|
|
44
|
+
"lint:check": "eslint 'src/**/*.{ts,tsx}' 'test/**/*.ts'",
|
|
45
|
+
"lint:fix": "eslint 'src/**/*.{ts,tsx}' 'test/**/*.ts' --fix",
|
|
46
|
+
"test-integration": "vitest --run --config test/integration/vitest.config.mts",
|
|
47
|
+
"test-unit": "vitest --run --coverage --config vitest.config.mts",
|
|
48
|
+
"test-unit:watch": "vitest --config vitest.config.mts",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
},
|
|
51
|
+
"lint-staged": {
|
|
52
|
+
"{src,test}/**/*.{ts,tsx}": [
|
|
53
|
+
"eslint --fix",
|
|
54
|
+
"prettier --write"
|
|
55
|
+
],
|
|
56
|
+
"*.{json{,5},md,y{,a}ml}": "prettier --write"
|
|
57
|
+
},
|
|
58
|
+
"prettier": "@abinnovision/prettier-config",
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
61
|
+
"rfc6902": "^5.3.0",
|
|
62
|
+
"zod": "^4.4.3"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@abinnovision/eslint-config-base": "^3.4.2",
|
|
66
|
+
"@abinnovision/prettier-config": "^2.2.0",
|
|
67
|
+
"@arethetypeswrong/core": "^0.18.5",
|
|
68
|
+
"@payloadcms/db-sqlite": "3.88.0",
|
|
69
|
+
"@payloadcms/richtext-lexical": "3.88.0",
|
|
70
|
+
"@payloadcms/ui": "3.88.0",
|
|
71
|
+
"@swc/core": "^1.16.1",
|
|
72
|
+
"@types/node": "^26.2.0",
|
|
73
|
+
"@types/react": "^19.2.18",
|
|
74
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
75
|
+
"eslint": "^10.7.0",
|
|
76
|
+
"payload": "3.88.0",
|
|
77
|
+
"prettier": "^3.9.5",
|
|
78
|
+
"publint": "^0.3.24",
|
|
79
|
+
"react": "^19.2.8",
|
|
80
|
+
"react-dom": "^19.2.8",
|
|
81
|
+
"tsdown": "^0.22.14",
|
|
82
|
+
"typescript": "^6.0.3",
|
|
83
|
+
"unplugin-swc": "^1.5.11",
|
|
84
|
+
"vitest": "^4.1.10"
|
|
85
|
+
},
|
|
86
|
+
"peerDependencies": {
|
|
87
|
+
"@payloadcms/ui": ">=3.88.0 <4",
|
|
88
|
+
"payload": ">=3.88.0 <4",
|
|
89
|
+
"react": "^19"
|
|
90
|
+
},
|
|
91
|
+
"peerDependenciesMeta": {
|
|
92
|
+
"@payloadcms/ui": {
|
|
93
|
+
"optional": true
|
|
94
|
+
},
|
|
95
|
+
"react": {
|
|
96
|
+
"optional": true
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"publishConfig": {
|
|
100
|
+
"ghpr": true,
|
|
101
|
+
"npm": true,
|
|
102
|
+
"npmAccess": "public"
|
|
103
|
+
}
|
|
104
|
+
}
|