@abinnovision/payloadcms-mcpx 2.0.0-beta.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +56 -36
- package/dist/admin/capability-matrix.d.mts +5 -3
- package/dist/admin/capability-matrix.mjs +374 -272
- package/dist/admin/capability-toggles.mjs +79 -42
- package/dist/admin/confirmations-panel.mjs +232 -0
- package/dist/admin/confirmations.d.mts +15 -0
- package/dist/admin/confirmations.mjs +71 -0
- package/dist/admin/endpoint-url.mjs +7 -0
- package/dist/admin/index.d.mts +2 -1
- package/dist/admin/index.mjs +2 -1
- package/dist/admin/setup-guide.d.mts +3 -3
- package/dist/admin/setup-guide.mjs +51 -33
- package/dist/api-keys/capability-matrix.d.mts +7 -9
- package/dist/api-keys/capability-matrix.mjs +15 -37
- package/dist/api-keys/collection.mjs +16 -18
- package/dist/api-keys/confirmation-view.mjs +7 -0
- package/dist/api-keys/fields.mjs +50 -57
- package/dist/auth/resolve.mjs +69 -40
- package/dist/capabilities.d.mts +2 -0
- package/dist/capabilities.mjs +64 -16
- package/dist/define-tool.d.mts +4 -4
- package/dist/define-tool.mjs +8 -1
- package/dist/endpoint/confirmations.mjs +124 -0
- package/dist/endpoint/handler.mjs +25 -27
- package/dist/endpoint/http.mjs +27 -0
- package/dist/endpoint/server.mjs +48 -29
- package/dist/endpoint/upload.mjs +104 -0
- package/dist/grants/confirmation.mjs +75 -0
- package/dist/grants/grant.mjs +137 -0
- package/dist/guards.mjs +8 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/options.mjs +68 -61
- package/dist/plugin.d.mts +3 -2
- package/dist/plugin.mjs +19 -4
- package/dist/request.mjs +5 -1
- package/dist/result.mjs +11 -1
- package/dist/schema/admin-hidden.mjs +4 -4
- package/dist/schema/describe.mjs +12 -18
- package/dist/schema/index.mjs +5 -6
- package/dist/schema/lexical-pointer.mjs +8 -8
- package/dist/schema/lexical.mjs +15 -13
- package/dist/schema/outline.mjs +4 -5
- package/dist/schema/path.mjs +4 -1
- package/dist/schema/pointer.mjs +11 -17
- package/dist/schema/shape.mjs +38 -50
- package/dist/schema/walk.mjs +57 -18
- package/dist/tools/builtin.mjs +7 -1
- package/dist/tools/create-document.mjs +38 -26
- package/dist/tools/delete-document.mjs +154 -0
- package/dist/tools/describe-schema.mjs +7 -5
- package/dist/tools/document.mjs +131 -45
- package/dist/tools/duplicate-document.mjs +50 -0
- package/dist/tools/find-documents.mjs +15 -20
- package/dist/tools/find-versions.mjs +11 -15
- package/dist/tools/get-document.mjs +31 -15
- package/dist/tools/links.mjs +53 -0
- package/dist/tools/list-capabilities.mjs +17 -7
- package/dist/tools/patch-document.mjs +57 -53
- package/dist/tools/publish-document.mjs +17 -29
- package/dist/tools/query-paths.mjs +1 -1
- package/dist/tools/run-confirmed.mjs +114 -0
- package/dist/tools/shared.mjs +53 -46
- package/dist/tools/validate-document.mjs +6 -8
- package/dist/tools/versions.mjs +11 -6
- package/dist/types.d.mts +158 -43
- package/dist/upload/file.mjs +177 -0
- package/dist/version.mjs +4 -2
- package/dist/write/draft-guard.mjs +35 -28
- package/dist/write/patch.mjs +4 -4
- package/dist/write/publish-intent.mjs +23 -11
- package/dist/write/row-ids.mjs +3 -1
- package/dist/write/write-data.mjs +18 -31
- package/package.json +14 -14
- package/dist/builtin-tool-names.mjs +0 -16
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { grantContext, issueGrant } from "../grants/grant.mjs";
|
|
2
|
+
import { publicOrigin } from "../request.mjs";
|
|
3
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
4
|
+
import { SchemaError } from "../schema/errors.mjs";
|
|
5
|
+
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
6
|
+
import "../schema/index.mjs";
|
|
7
|
+
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { APIError } from "payload";
|
|
10
|
+
import { hasDraftValidationEnabled, hasDraftsEnabled, validateMimeType } from "payload/shared";
|
|
11
|
+
//#region src/upload/file.ts
|
|
12
|
+
const MAX_BYTES = 26214400;
|
|
13
|
+
const FILENAME = /^[^/\\\p{Cc}]+$/u;
|
|
14
|
+
const MIME_TYPE = /^[\w.+-]+\/[\w.+-]+$/;
|
|
15
|
+
const uploadedFiles = /* @__PURE__ */ new WeakMap();
|
|
16
|
+
const fileSchema = (maxBytes) => z.strictObject({
|
|
17
|
+
filename: z.string().max(255).regex(FILENAME),
|
|
18
|
+
mimeType: z.string().max(255).regex(MIME_TYPE),
|
|
19
|
+
size: z.number().int().min(1).max(maxBytes)
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* The largest file accepted: Payload's `upload.limits.fileSize` if set, and
|
|
23
|
+
* never more than 25 MB, since the body is buffered.
|
|
24
|
+
*/ const uploadMaxBytes = (config) => Math.min(config.upload.limits?.fileSize ?? MAX_BYTES, MAX_BYTES);
|
|
25
|
+
const allowedMimeTypes = (scope, slug) => {
|
|
26
|
+
if (!scope.uploads || !scope.collections.writable.includes(slug)) return [];
|
|
27
|
+
const entity = scope.exposure.collections.find((candidate) => candidate.slug === slug);
|
|
28
|
+
const config = scope.req.payload.collections[slug]?.config;
|
|
29
|
+
return entity?.isUpload === true && Array.isArray(config?.upload.mimeTypes) ? config.upload.mimeTypes : [];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Whether this key may send a file to `slug`: a writable upload collection
|
|
33
|
+
* that sets `upload.mimeTypes`.
|
|
34
|
+
*/ const acceptsFiles = (scope, slug) => allowedMimeTypes(scope, slug).length > 0;
|
|
35
|
+
/**
|
|
36
|
+
* The collections this key may send files to.
|
|
37
|
+
*/ const fileSlugs = (scope) => scope.collections.writable.filter((slug) => acceptsFiles(scope, slug));
|
|
38
|
+
/**
|
|
39
|
+
* The `file` argument, present only when the key may send a file somewhere.
|
|
40
|
+
*/ const fileShape = (scope, description) => {
|
|
41
|
+
const slugs = fileSlugs(scope);
|
|
42
|
+
return slugs.length === 0 ? {} : { file: fileSchema(uploadMaxBytes(scope.req.payload.config)).optional().describe(description(slugs.join(", "))) };
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Throws for a `file` sent to an entity that does not accept one, or declared
|
|
46
|
+
* with a type outside its `upload.mimeTypes`. Payload stores the declared
|
|
47
|
+
* type, so it is held to the same list as the content.
|
|
48
|
+
*/ const assertAcceptsFile = (scope, target, file) => {
|
|
49
|
+
const mimeTypes = target.kind === "global" ? [] : allowedMimeTypes(scope, target.slug);
|
|
50
|
+
if (mimeTypes.length === 0) throw new APIError(`"${target.slug}" does not accept files through MCP: it is not an upload collection, or it sets no upload.mimeTypes.`, 400);
|
|
51
|
+
if (!validateMimeType(file.mimeType, mimeTypes)) throw new APIError(`"${target.slug}" accepts only ${mimeTypes.join(", ")}, not "${file.mimeType}".`, 400);
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Attaches the claimed bytes to the request the tool runs on.
|
|
55
|
+
*/ const setUploadedFile = (req, file) => {
|
|
56
|
+
uploadedFiles.set(req, file);
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The bytes for this call, or `undefined` before they were sent.
|
|
60
|
+
*/ const uploadedFile = (req) => uploadedFiles.get(req);
|
|
61
|
+
const sharesPublishedFile = async (req, entity, id) => {
|
|
62
|
+
if (!hasDraftsEnabled(entity.config)) return false;
|
|
63
|
+
const read = {
|
|
64
|
+
collection: entity.slug,
|
|
65
|
+
id,
|
|
66
|
+
depth: 0,
|
|
67
|
+
overrideAccess: true,
|
|
68
|
+
disableErrors: true,
|
|
69
|
+
select: {
|
|
70
|
+
filename: true,
|
|
71
|
+
_status: true
|
|
72
|
+
},
|
|
73
|
+
req
|
|
74
|
+
};
|
|
75
|
+
const [latest, published] = await Promise.all([req.payload.findByID({
|
|
76
|
+
...read,
|
|
77
|
+
draft: true
|
|
78
|
+
}), req.payload.findByID({
|
|
79
|
+
...read,
|
|
80
|
+
draft: false
|
|
81
|
+
})]);
|
|
82
|
+
return latest?.["_status"] === "draft" && published?.["_status"] === "published" && latest["filename"] === published["filename"];
|
|
83
|
+
};
|
|
84
|
+
const isClientWritable = (scope, target, path) => {
|
|
85
|
+
try {
|
|
86
|
+
const resolution = resolveDataPointer(scope.req.payload.config, {
|
|
87
|
+
ref: target.entity,
|
|
88
|
+
doc: target.data,
|
|
89
|
+
pointer: path
|
|
90
|
+
});
|
|
91
|
+
return resolution.readOnly !== true && resolution.descriptor?.readOnly !== true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof SchemaError) return false;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Refuses a file write Payload would turn into data loss: a replace that
|
|
99
|
+
* deletes the published file, or a write that fails validation after the old
|
|
100
|
+
* file is gone. Payload validates only after it moves files, so the merged
|
|
101
|
+
* data is validated here first wherever Payload will validate it, keeping only
|
|
102
|
+
* failures the client can fix. Once the bytes are attached, Payload validates
|
|
103
|
+
* with every hook and the file.
|
|
104
|
+
*/ const fileWriteRefusal = async (scope, target) => {
|
|
105
|
+
const { entity, id } = target;
|
|
106
|
+
if (id !== void 0 && await sharesPublishedFile(scope.req, entity, id)) return errorResult("The file was not replaced: the latest draft still uses the published file, which the replace would delete. Publish or discard the draft first.");
|
|
107
|
+
if (uploadedFile(scope.req) !== void 0 || hasDraftsEnabled(entity.config) && !hasDraftValidationEnabled(entity.config)) return;
|
|
108
|
+
const { blockers: found } = await collectPublishBlockers(scope.req, {
|
|
109
|
+
doc: {
|
|
110
|
+
...target.data,
|
|
111
|
+
...id === void 0 ? {} : { id }
|
|
112
|
+
},
|
|
113
|
+
entity
|
|
114
|
+
});
|
|
115
|
+
const blockers = found.filter((blocker) => isClientWritable(scope, target, blocker.path));
|
|
116
|
+
return blockers.length === 0 ? void 0 : errorResult("Nothing was written: the document fails validation.", { validationErrors: blockers });
|
|
117
|
+
};
|
|
118
|
+
const handoffUrl = (req, path) => {
|
|
119
|
+
const url = new URL(publicOrigin(req));
|
|
120
|
+
url.pathname = `${new URL(req.url ?? "").pathname.replace(/\/+$/, "")}/${path}`;
|
|
121
|
+
return url.toString();
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Stores the call as a grant and returns where to PUT its file. Nothing else
|
|
125
|
+
* is written: the PUT runs the same call again with the bytes attached.
|
|
126
|
+
*/ const uploadHandoff = async (scope, call) => {
|
|
127
|
+
const { req } = scope;
|
|
128
|
+
const { payload } = req;
|
|
129
|
+
const { slug, apiKeyId } = grantContext(req);
|
|
130
|
+
const url = handoffUrl(req, "upload");
|
|
131
|
+
const { id, exp } = await issueGrant(payload, slug, {
|
|
132
|
+
kind: "upload",
|
|
133
|
+
apiKeyId,
|
|
134
|
+
tool: call.tool,
|
|
135
|
+
args: call.args,
|
|
136
|
+
file: call.args.file,
|
|
137
|
+
locale: req.locale ?? null,
|
|
138
|
+
fallbackLocale: req.fallbackLocale ?? null
|
|
139
|
+
});
|
|
140
|
+
return jsonResult({ upload: {
|
|
141
|
+
url,
|
|
142
|
+
method: "PUT",
|
|
143
|
+
headers: {
|
|
144
|
+
"content-type": call.args.file.mimeType,
|
|
145
|
+
"x-mcpx-grant": id
|
|
146
|
+
},
|
|
147
|
+
expiresAt: new Date(exp).toISOString(),
|
|
148
|
+
maxBytes: uploadMaxBytes(payload.config)
|
|
149
|
+
} });
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* The upload collections whose files this key may download. A collection
|
|
153
|
+
* with its own top-level `download` field is left out, since the handoff
|
|
154
|
+
* would overwrite it in the result.
|
|
155
|
+
*/ const downloadSlugs = (scope) => scope.uploads ? scope.collections.readable.filter((slug) => scope.exposure.collections.find((candidate) => candidate.slug === slug)?.isUpload === true && !scope.req.payload.collections[slug]?.config.flattenedFields.some((field) => field.name === "download")) : [];
|
|
156
|
+
/**
|
|
157
|
+
* Stores a grant for the file of a document just read and returns where to
|
|
158
|
+
* GET it.
|
|
159
|
+
*/ const downloadHandoff = async (scope, target) => {
|
|
160
|
+
const { req } = scope;
|
|
161
|
+
const { slug, apiKeyId } = grantContext(req);
|
|
162
|
+
const url = handoffUrl(req, "file");
|
|
163
|
+
const { id, exp } = await issueGrant(req.payload, slug, {
|
|
164
|
+
kind: "download",
|
|
165
|
+
apiKeyId,
|
|
166
|
+
...target,
|
|
167
|
+
fallbackLocale: req.fallbackLocale ?? null
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
url,
|
|
171
|
+
method: "GET",
|
|
172
|
+
headers: { "x-mcpx-grant": id },
|
|
173
|
+
expiresAt: new Date(exp).toISOString()
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
//#endregion
|
|
177
|
+
export { acceptsFiles, assertAcceptsFile, downloadHandoff, downloadSlugs, fileShape, fileSlugs, fileWriteRefusal, setUploadedFile, uploadHandoff, uploadMaxBytes, uploadedFile };
|
package/dist/version.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
//#region src/version.ts
|
|
2
2
|
/**
|
|
3
3
|
* Package version injected at build time; sources under Vitest report "dev".
|
|
4
|
-
*/ const MCPX_VERSION = "
|
|
4
|
+
*/ const MCPX_VERSION = "2.0.0";
|
|
5
|
+
const MCPX_REPOSITORY_URL = "https://github.com/abinnovision/payloadcms-commons/tree/main/packages/mcpx";
|
|
6
|
+
const MCPX_ISSUES_URL = "https://github.com/abinnovision/payloadcms-commons/issues";
|
|
5
7
|
//#endregion
|
|
6
|
-
export { MCPX_VERSION };
|
|
8
|
+
export { MCPX_ISSUES_URL, MCPX_REPOSITORY_URL, MCPX_VERSION };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { propOf } from "../guards.mjs";
|
|
2
2
|
import { isMcpxRequest } from "../request.mjs";
|
|
3
|
+
import { hasPublishIntent, hasTrashIntent, takeDuplicateIntent, takePublishIntent, takeTrashIntent, withTrashIntent } from "./publish-intent.mjs";
|
|
3
4
|
import { APIError } from "payload";
|
|
4
5
|
import { hasDraftsEnabled } from "payload/shared";
|
|
5
6
|
//#region src/write/draft-guard.ts
|
|
@@ -11,16 +12,17 @@ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
|
11
12
|
"selectedLocales",
|
|
12
13
|
"overwriteExistingFiles"
|
|
13
14
|
]);
|
|
14
|
-
const scrubWriteArgs = (args,
|
|
15
|
+
const scrubWriteArgs = (args, intent) => {
|
|
16
|
+
const publishing = intent === "publish";
|
|
15
17
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key) && (publishing || key !== "publishSpecificLocale")));
|
|
16
18
|
if (next["data"] && typeof next["data"] === "object") {
|
|
17
|
-
const { _status: _ignoredStatus, deletedAt
|
|
19
|
+
const { _status: _ignoredStatus, deletedAt, ...data } = next["data"];
|
|
18
20
|
next["data"] = publishing ? {
|
|
19
21
|
...data,
|
|
20
22
|
_status: "published"
|
|
21
|
-
} : data;
|
|
23
|
+
} : intent === "trash" ? withTrashIntent({ deletedAt }) : data;
|
|
22
24
|
}
|
|
23
|
-
next["draft"] =
|
|
25
|
+
next["draft"] = intent === void 0;
|
|
24
26
|
next["autosave"] = false;
|
|
25
27
|
next["overrideLock"] = false;
|
|
26
28
|
next["trash"] = false;
|
|
@@ -29,8 +31,13 @@ const scrubWriteArgs = (args, publishing) => {
|
|
|
29
31
|
const forceDraftWrite = (hookArgs) => {
|
|
30
32
|
const { args, operation, req } = hookArgs;
|
|
31
33
|
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
if (operation === "create" && takeDuplicateIntent(args.data)) {
|
|
35
|
+
const next = scrubWriteArgs(args, void 0);
|
|
36
|
+
next["duplicateFromID"] = args.duplicateFromID;
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
const intent = operation !== "update" ? void 0 : hasPublishIntent(args.data) ? "publish" : hasTrashIntent(args.data) ? "trash" : void 0;
|
|
40
|
+
return scrubWriteArgs(args, intent);
|
|
34
41
|
};
|
|
35
42
|
/**
|
|
36
43
|
* The global counterpart of {@link forceDraftWrite}. `updateGlobal` reads
|
|
@@ -46,12 +53,13 @@ const forceDraftWrite = (hookArgs) => {
|
|
|
46
53
|
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${global.slug} that did not ask for a draft save.`);
|
|
47
54
|
throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
|
|
48
55
|
}
|
|
49
|
-
return scrubWriteArgs(args, publishing);
|
|
56
|
+
return scrubWriteArgs(args, publishing ? "publish" : void 0);
|
|
50
57
|
};
|
|
51
58
|
const refuseUnlessExpected = (req, slug, data) => {
|
|
52
59
|
const publishing = takePublishIntent(data);
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
const trashing = takeTrashIntent(data);
|
|
61
|
+
if (!isMcpxRequest(req) || trashing) return;
|
|
62
|
+
const status = propOf(data, "_status");
|
|
55
63
|
const expected = publishing ? "published" : "draft";
|
|
56
64
|
if (status === expected) return;
|
|
57
65
|
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
|
|
@@ -69,30 +77,29 @@ const refusePublishGlobal = ({ data, global, req }) => {
|
|
|
69
77
|
refuseUnlessExpected(req, global.slug, next);
|
|
70
78
|
return next;
|
|
71
79
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
* (it does nothing outside MCP requests) and `refusePublish` wherever drafts
|
|
75
|
-
* exist. It runs on the final collection list, so no collection escapes it, and
|
|
76
|
-
* both hooks are appended last, so a user hook cannot override them.
|
|
77
|
-
*/ const installDraftGuards = (collections) => collections.map((collection) => ({
|
|
78
|
-
...collection,
|
|
80
|
+
const appendGuards = (entities, guards) => entities.map((entity) => ({
|
|
81
|
+
...entity,
|
|
79
82
|
hooks: {
|
|
80
|
-
...
|
|
81
|
-
beforeOperation: [...
|
|
82
|
-
...hasDraftsEnabled(
|
|
83
|
+
...entity.hooks,
|
|
84
|
+
beforeOperation: [...entity.hooks?.beforeOperation ?? [], guards.beforeOperation],
|
|
85
|
+
...hasDraftsEnabled(entity) ? { beforeChange: [...entity.hooks?.beforeChange ?? [], guards.beforeChange] } : {}
|
|
83
86
|
}
|
|
84
87
|
}));
|
|
85
88
|
/**
|
|
89
|
+
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
90
|
+
* (it does nothing outside MCP requests) and `refusePublish` wherever drafts
|
|
91
|
+
* exist. It runs on the final collection list, so no collection escapes it.
|
|
92
|
+
*/ const installDraftGuards = (collections) => appendGuards(collections, {
|
|
93
|
+
beforeChange: refusePublish,
|
|
94
|
+
beforeOperation: forceDraftWrite
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
86
97
|
* Attaches the guard to every global, exposed or not: a custom tool running on
|
|
87
98
|
* an MCP request must not be able to publish through a global the plugin config
|
|
88
99
|
* never mentioned.
|
|
89
|
-
*/ const installGlobalDraftGuards = (globals) => globals
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
beforeOperation: [...global.hooks?.beforeOperation ?? [], forceDraftWriteGlobal],
|
|
94
|
-
...hasDraftsEnabled(global) ? { beforeChange: [...global.hooks?.beforeChange ?? [], refusePublishGlobal] } : {}
|
|
95
|
-
}
|
|
96
|
-
}));
|
|
100
|
+
*/ const installGlobalDraftGuards = (globals) => appendGuards(globals, {
|
|
101
|
+
beforeChange: refusePublishGlobal,
|
|
102
|
+
beforeOperation: forceDraftWriteGlobal
|
|
103
|
+
});
|
|
97
104
|
//#endregion
|
|
98
105
|
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };
|
package/dist/write/patch.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { isIndexSegment, joinPath, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
|
|
2
|
-
import { RESERVED_FIELD_NAMES, descriptorsUnder } from "../schema/walk.mjs";
|
|
3
1
|
import { SchemaError } from "../schema/errors.mjs";
|
|
2
|
+
import { isIndexSegment, parentPointer, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
|
|
3
|
+
import { RESERVED_FIELD_NAMES, descriptorsUnder } from "../schema/walk.mjs";
|
|
4
4
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
5
5
|
import { EMPTY_ROOT, validateWriteValue } from "../schema/shape.mjs";
|
|
6
6
|
import "../schema/index.mjs";
|
|
@@ -37,7 +37,7 @@ const resolvesReadOnly = (resolution) => {
|
|
|
37
37
|
};
|
|
38
38
|
const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
|
|
39
39
|
doc: target.doc,
|
|
40
|
-
pointer: isElementPointer(target.pointer) ?
|
|
40
|
+
pointer: isElementPointer(target.pointer) ? parentPointer(target.pointer) : target.pointer,
|
|
41
41
|
ref: target.ref
|
|
42
42
|
}));
|
|
43
43
|
const resolutionAt = (config, target) => {
|
|
@@ -60,7 +60,7 @@ const droppedFieldProblem = (config, target) => {
|
|
|
60
60
|
return `"${target.pointer}" 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.`;
|
|
61
61
|
};
|
|
62
62
|
const emptiesTheRoot = (config, target) => {
|
|
63
|
-
const list =
|
|
63
|
+
const list = parentPointer(target.pointer);
|
|
64
64
|
const owner = resolutionAt(config, {
|
|
65
65
|
...target,
|
|
66
66
|
pointer: list
|
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import { isPlainObject } from "../guards.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
//#region src/write/publish-intent.ts
|
|
4
|
-
const PUBLISH_INTENT = "__mcpxPublishIntent";
|
|
5
4
|
const TOKEN = randomUUID();
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const intent = (key) => ({
|
|
6
|
+
has: (data) => isPlainObject(data) && data[key] === TOKEN,
|
|
7
|
+
take: (data) => {
|
|
8
|
+
if (!isPlainObject(data) || data[key] !== TOKEN) return false;
|
|
9
|
+
delete data[key];
|
|
10
|
+
return true;
|
|
11
|
+
},
|
|
12
|
+
with: (data) => ({
|
|
13
|
+
...data,
|
|
14
|
+
[key]: TOKEN
|
|
15
|
+
})
|
|
9
16
|
});
|
|
10
|
-
const
|
|
17
|
+
const publish = intent("__mcpxPublishIntent");
|
|
18
|
+
const trash = intent("__mcpxTrashIntent");
|
|
19
|
+
const duplicate = intent("__mcpxDuplicateIntent");
|
|
20
|
+
const withPublishIntent = publish.with;
|
|
21
|
+
const hasPublishIntent = publish.has;
|
|
11
22
|
/**
|
|
12
23
|
* Called by the last hook that needs the marker, which removes it.
|
|
13
|
-
*/ const takePublishIntent =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
*/ const takePublishIntent = publish.take;
|
|
25
|
+
const withTrashIntent = trash.with;
|
|
26
|
+
const hasTrashIntent = trash.has;
|
|
27
|
+
const takeTrashIntent = trash.take;
|
|
28
|
+
const withDuplicateIntent = duplicate.with;
|
|
29
|
+
const takeDuplicateIntent = duplicate.take;
|
|
18
30
|
//#endregion
|
|
19
|
-
export { hasPublishIntent, takePublishIntent, withPublishIntent };
|
|
31
|
+
export { hasPublishIntent, hasTrashIntent, takeDuplicateIntent, takePublishIntent, takeTrashIntent, withDuplicateIntent, withPublishIntent, withTrashIntent };
|
package/dist/write/row-ids.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { isPlainObject } from "../guards.mjs";
|
|
2
|
+
import { editorRoot } from "../schema/lexical.mjs";
|
|
3
|
+
import "../schema/index.mjs";
|
|
2
4
|
//#region src/write/row-ids.ts
|
|
3
|
-
const isRichTextState = (value) =>
|
|
5
|
+
const isRichTextState = (value) => Array.isArray(editorRoot(value)?.["children"]);
|
|
4
6
|
const walkRows = (value, visit, isRow = false) => {
|
|
5
7
|
if (Array.isArray(value)) {
|
|
6
8
|
for (const entry of value) walkRows(entry, visit, true);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isPlainObject } from "../guards.mjs";
|
|
2
2
|
import { splitPath } from "../schema/path.mjs";
|
|
3
|
-
import { ROW_KEYS,
|
|
3
|
+
import { ROW_KEYS, blockForRow, classifyKey, descriptorsUnder, findFieldAt } from "../schema/walk.mjs";
|
|
4
4
|
import "../schema/index.mjs";
|
|
5
5
|
//#region src/write/write-data.ts
|
|
6
6
|
const pickDescribed = (config, value, at) => {
|
|
@@ -10,43 +10,30 @@ const pickDescribed = (config, value, at) => {
|
|
|
10
10
|
if (isRow) {
|
|
11
11
|
for (const key of ROW_KEYS) if (Object.hasOwn(value, key)) result[key] = value[key];
|
|
12
12
|
}
|
|
13
|
+
const pick = (row, at) => isPlainObject(row) ? pickDescribed(config, row, at) : row;
|
|
13
14
|
for (const [key, entry] of Object.entries(value)) {
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return block && isPlainObject(row) ? pickDescribed(config, row, {
|
|
15
|
+
const found = classifyKey(relative, prefix, key);
|
|
16
|
+
if (found.kind === "none") continue;
|
|
17
|
+
if (found.kind === "leaf") {
|
|
18
|
+
const field = found.descriptor.type === "blocks" ? findFieldAt(fields, splitPath(found.descriptor.path), "blocks") : void 0;
|
|
19
|
+
result[key] = field && Array.isArray(entry) ? entry.map((row) => {
|
|
20
|
+
const block = blockForRow(config, field, row);
|
|
21
|
+
return block ? pick(row, {
|
|
22
22
|
fields: block.flattenedFields,
|
|
23
23
|
prefix: [],
|
|
24
24
|
isRow: true
|
|
25
25
|
}) : row;
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
if (exact) {
|
|
30
|
-
result[key] = entry;
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
if (candidates.some(({ parts }) => parts[1] === "*")) {
|
|
34
|
-
result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
|
|
35
|
-
fields,
|
|
36
|
-
prefix: [
|
|
37
|
-
...prefix,
|
|
38
|
-
key,
|
|
39
|
-
"*"
|
|
40
|
-
],
|
|
41
|
-
isRow: true
|
|
42
|
-
}) : row) : entry;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
result[key] = isPlainObject(entry) ? pickDescribed(config, entry, {
|
|
26
|
+
}) : entry;
|
|
27
|
+
} else if (found.kind === "rows") result[key] = Array.isArray(entry) ? entry.map((row) => pick(row, {
|
|
46
28
|
fields,
|
|
47
|
-
prefix:
|
|
29
|
+
prefix: found.prefix,
|
|
30
|
+
isRow: true
|
|
31
|
+
})) : entry;
|
|
32
|
+
else result[key] = pick(entry, {
|
|
33
|
+
fields,
|
|
34
|
+
prefix: found.prefix,
|
|
48
35
|
isRow: false
|
|
49
|
-
})
|
|
36
|
+
});
|
|
50
37
|
}
|
|
51
38
|
return result;
|
|
52
39
|
};
|
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": "2.0.0
|
|
4
|
+
"version": "2.0.0",
|
|
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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"prettier": "@abinnovision/prettier-config",
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
60
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
61
61
|
"rfc6902": "^5.3.0",
|
|
62
62
|
"zod": "^4.6.5"
|
|
63
63
|
},
|
|
@@ -66,23 +66,23 @@
|
|
|
66
66
|
"@abinnovision/prettier-config": "^2.2.0",
|
|
67
67
|
"@arethetypeswrong/core": "^0.18.5",
|
|
68
68
|
"@internal/test-utils": "0.0.0",
|
|
69
|
-
"@payloadcms/db-sqlite": "3.
|
|
70
|
-
"@payloadcms/richtext-lexical": "3.
|
|
71
|
-
"@payloadcms/ui": "3.
|
|
72
|
-
"@swc/core": "^1.16.
|
|
73
|
-
"@types/node": "^26.
|
|
69
|
+
"@payloadcms/db-sqlite": "3.90.2",
|
|
70
|
+
"@payloadcms/richtext-lexical": "3.90.2",
|
|
71
|
+
"@payloadcms/ui": "3.90.2",
|
|
72
|
+
"@swc/core": "^1.16.13",
|
|
73
|
+
"@types/node": "^26.6.3",
|
|
74
74
|
"@types/react": "^19.3.0",
|
|
75
|
-
"@vitest/coverage-v8": "^5.0.
|
|
76
|
-
"eslint": "^10.
|
|
77
|
-
"payload": "3.
|
|
78
|
-
"prettier": "^3.9.
|
|
79
|
-
"publint": "^0.3.
|
|
75
|
+
"@vitest/coverage-v8": "^5.0.3",
|
|
76
|
+
"eslint": "^10.12.0",
|
|
77
|
+
"payload": "3.90.2",
|
|
78
|
+
"prettier": "^3.9.9",
|
|
79
|
+
"publint": "^0.3.25",
|
|
80
80
|
"react": "^19.3.0",
|
|
81
81
|
"react-dom": "^19.3.0",
|
|
82
82
|
"tsdown": "^0.23.0",
|
|
83
83
|
"typescript": "^6.0.3",
|
|
84
|
-
"unplugin-swc": "^
|
|
85
|
-
"vitest": "^5.0.
|
|
84
|
+
"unplugin-swc": "^2.0.0",
|
|
85
|
+
"vitest": "^5.0.3"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
88
|
"@payloadcms/ui": ">=3.88.0 <4",
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
//#region src/builtin-tool-names.ts
|
|
2
|
-
/**
|
|
3
|
-
* Names of the builtin tools. Custom tools must not reuse them.
|
|
4
|
-
*/ const BUILTIN_TOOL_NAMES = [
|
|
5
|
-
"listCapabilities",
|
|
6
|
-
"describeSchema",
|
|
7
|
-
"findDocuments",
|
|
8
|
-
"getDocument",
|
|
9
|
-
"findVersions",
|
|
10
|
-
"patchDocument",
|
|
11
|
-
"createDocument",
|
|
12
|
-
"validateDocument",
|
|
13
|
-
"publishDocument"
|
|
14
|
-
];
|
|
15
|
-
//#endregion
|
|
16
|
-
export { BUILTIN_TOOL_NAMES };
|