@abinnovision/payloadcms-mcpx 1.1.0-beta.5 → 2.0.0-beta.7
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 +141 -448
- package/dist/{client → admin}/capability-matrix.d.mts +3 -4
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +1 -1
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +6 -6
- package/dist/api-keys/capability-matrix.mjs +15 -85
- package/dist/api-keys/fields.mjs +15 -24
- package/dist/auth/resolve.mjs +23 -5
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +2 -1
- package/dist/capabilities.mjs +34 -42
- package/dist/define-tool.d.mts +19 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +49 -19
- package/dist/endpoint/server.mjs +65 -19
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +5 -0
- package/dist/i18n.mjs +8 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +51 -43
- package/dist/plugin.mjs +0 -1
- package/dist/request.d.mts +2 -3
- package/dist/request.mjs +2 -3
- package/dist/result.d.mts +2 -3
- package/dist/result.mjs +3 -3
- package/dist/schema/admin-hidden.mjs +91 -0
- package/dist/schema/describe.mjs +17 -33
- package/dist/schema/errors.mjs +8 -0
- package/dist/schema/index.mjs +5 -2
- package/dist/schema/lexical-pointer.mjs +18 -20
- package/dist/schema/lexical.mjs +39 -92
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +22 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +35 -45
- package/dist/tools/builtin.mjs +6 -4
- package/dist/tools/create-document.mjs +26 -23
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +84 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +74 -0
- package/dist/tools/get-document.mjs +80 -49
- package/dist/tools/list-capabilities.mjs +25 -16
- package/dist/tools/patch-document.mjs +74 -50
- package/dist/tools/publish-document.mjs +17 -28
- package/dist/tools/query-paths.mjs +55 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +65 -96
- package/dist/tools/validate-document.mjs +16 -29
- package/dist/tools/versions.mjs +127 -0
- package/dist/types.d.mts +55 -55
- package/dist/write/draft-guard.mjs +17 -37
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +4 -4
- package/dist/write/row-ids.mjs +44 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +4 -4
- package/dist/client/capability-matrix.mjs +0 -349
- package/dist/endpoint/index.mjs +0 -4
- package/dist/tools/target.mjs +0 -45
- /package/dist/{client → admin}/index.d.mts +0 -0
- /package/dist/{client → admin}/index.mjs +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createDataloaderCacheKey, getDataLoader, isolateObjectProperty } from "payload";
|
|
2
|
+
//#region src/tools/read-request.ts
|
|
3
|
+
const LOADER_KEY = (() => {
|
|
4
|
+
const slug = "\0slug";
|
|
5
|
+
const id = "\0id";
|
|
6
|
+
const parts = JSON.parse(createDataloaderCacheKey({
|
|
7
|
+
collectionSlug: slug,
|
|
8
|
+
currentDepth: 0,
|
|
9
|
+
depth: 0,
|
|
10
|
+
docID: id,
|
|
11
|
+
draft: false,
|
|
12
|
+
fallbackLocale: false,
|
|
13
|
+
locale: "",
|
|
14
|
+
overrideAccess: false,
|
|
15
|
+
showHiddenFields: false,
|
|
16
|
+
transactionID: ""
|
|
17
|
+
}));
|
|
18
|
+
return Array.isArray(parts) ? {
|
|
19
|
+
slug: parts.indexOf(slug),
|
|
20
|
+
id: parts.indexOf(id)
|
|
21
|
+
} : {
|
|
22
|
+
slug: -1,
|
|
23
|
+
id: -1
|
|
24
|
+
};
|
|
25
|
+
})();
|
|
26
|
+
/**
|
|
27
|
+
* A read request that populates only into collections this key may read, at
|
|
28
|
+
* every depth, through a data loader of its own. A refused relation resolves to
|
|
29
|
+
* its id. Each call returns a fresh request.
|
|
30
|
+
*/ const mcpxReadRequest = (scope) => {
|
|
31
|
+
const req = isolateObjectProperty(scope.req, "payloadDataLoader");
|
|
32
|
+
const loader = getDataLoader(req);
|
|
33
|
+
const load = loader.load.bind(loader);
|
|
34
|
+
loader.load = (key) => {
|
|
35
|
+
const parts = JSON.parse(key);
|
|
36
|
+
const collection = parts[LOADER_KEY.slug];
|
|
37
|
+
return typeof collection === "string" && scope.collections.readable.includes(collection) ? load(key) : Promise.resolve(parts[LOADER_KEY.id]);
|
|
38
|
+
};
|
|
39
|
+
req.payloadDataLoader = loader;
|
|
40
|
+
return req;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { mcpxReadRequest };
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -1,149 +1,118 @@
|
|
|
1
|
-
import { canCreate,
|
|
2
|
-
import { translateStatic } from "../i18n.mjs";
|
|
3
|
-
import { NotFound } from "payload";
|
|
1
|
+
import { canCreate, isLiveWrite } from "../capabilities.mjs";
|
|
4
2
|
import { z } from "zod";
|
|
5
3
|
//#region src/tools/shared.ts
|
|
6
4
|
/**
|
|
7
5
|
* An out-of-scope slug fails schema validation before a handler runs, so a
|
|
8
6
|
* client only ever sees what its key may touch.
|
|
9
7
|
*/ const slugEnum = (slugs) => z.enum(slugs);
|
|
10
|
-
/** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()])
|
|
8
|
+
/** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]);
|
|
9
|
+
const slugsOf = (scope, key) => ({
|
|
10
|
+
collections: scope.collections[key],
|
|
11
|
+
globals: scope.globals[key]
|
|
12
|
+
});
|
|
11
13
|
const slugsWhere = (scope, predicate, allowed) => {
|
|
12
14
|
const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
|
|
13
15
|
return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
|
|
14
16
|
};
|
|
15
17
|
/**
|
|
16
|
-
* Slugs this key may write whose writes land live rather than as a draft. An
|
|
17
|
-
* entity without versions has no draft to land on, so `write: "live"` there
|
|
18
|
-
* makes every write a live one. Empty for every key that can only write drafts.
|
|
19
|
-
*/ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
|
|
20
|
-
collections: scope.writable,
|
|
21
|
-
globals: scope.writableGlobals
|
|
22
|
-
});
|
|
23
|
-
/**
|
|
24
18
|
* Slugs this key may write but never create in, because their documents are
|
|
25
19
|
* files. Collection-only, since nothing creates a global either way.
|
|
26
20
|
*/ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
|
|
27
|
-
collections: scope.writable,
|
|
21
|
+
collections: scope.collections.writable,
|
|
28
22
|
globals: []
|
|
29
23
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
globals: scope.publishableGlobals
|
|
33
|
-
});
|
|
34
|
-
/**
|
|
35
|
-
* What a write actually does for this key, and what it takes to make it public.
|
|
36
|
-
* A live-write slug has no draft and no publish step; a publishable one has
|
|
37
|
-
* both. Stated per key so a client is never told its writes are drafts while
|
|
38
|
-
* they are not, nor that publishing is out of reach when it is not.
|
|
39
|
-
*/ const draftSentence = (scope) => {
|
|
40
|
-
const live = liveWriteSlugs(scope);
|
|
41
|
-
const publishable = publishableWriteSlugs(scope);
|
|
42
|
-
return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
|
|
43
|
-
};
|
|
44
|
-
/** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
45
|
-
/** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
|
|
46
|
-
/** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
|
|
24
|
+
const widen = (branch) => branch;
|
|
25
|
+
/** The one list the shape helpers and {@link resolveEntity} both read. */ const slugsFor = (scope, operation) => {
|
|
47
26
|
switch (operation) {
|
|
48
27
|
case "create": return {
|
|
49
28
|
collections: slugsWhere(scope, canCreate, {
|
|
50
|
-
collections: scope.writable,
|
|
29
|
+
collections: scope.collections.writable,
|
|
51
30
|
globals: []
|
|
52
31
|
}),
|
|
53
32
|
globals: []
|
|
54
33
|
};
|
|
55
|
-
case "publish": return
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
34
|
+
case "publish": return slugsOf(scope, "publishable");
|
|
35
|
+
case "read": return slugsOf(scope, "readable");
|
|
36
|
+
case "versions": return {
|
|
37
|
+
collections: slugsWhere(scope, (entity) => entity.hasVersions, {
|
|
38
|
+
collections: scope.collections.readable,
|
|
39
|
+
globals: []
|
|
40
|
+
}),
|
|
41
|
+
globals: slugsWhere(scope, (entity) => entity.hasVersions, {
|
|
42
|
+
collections: [],
|
|
43
|
+
globals: scope.globals.readable
|
|
44
|
+
})
|
|
66
45
|
};
|
|
46
|
+
case "write": return slugsOf(scope, "writable");
|
|
67
47
|
}
|
|
68
48
|
};
|
|
69
49
|
/**
|
|
50
|
+
* The slugs of {@link slugsFor} `"versions"` that also have drafts. Only they
|
|
51
|
+
* have `_status` on their versions, so only they can be queried by status.
|
|
52
|
+
*/ const draftVersionSlugs = (scope) => {
|
|
53
|
+
const { collections, globals } = slugsFor(scope, "versions");
|
|
54
|
+
return {
|
|
55
|
+
collections: slugsWhere(scope, (entity) => entity.hasDrafts, {
|
|
56
|
+
collections,
|
|
57
|
+
globals: []
|
|
58
|
+
}),
|
|
59
|
+
globals: slugsWhere(scope, (entity) => entity.hasDrafts, {
|
|
60
|
+
collections: [],
|
|
61
|
+
globals
|
|
62
|
+
})
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
/** Slugs reachable by `operation` whose writes go live, because they have no draft to write. */ const liveWriteSlugs = (scope, operation) => slugsWhere(scope, isLiveWrite, slugsFor(scope, operation));
|
|
66
|
+
/**
|
|
67
|
+
* Stated on the tools that write, since a client calling them must know before
|
|
68
|
+
* the call which writes are public.
|
|
69
|
+
*/ const liveWriteSentence = (scope, operation) => {
|
|
70
|
+
const live = liveWriteSlugs(scope, operation);
|
|
71
|
+
return live.length === 0 ? "Every write is saved as a draft." : `Writes to ${live.join(", ")} go live immediately. Every other write is saved as a draft.`;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
70
74
|
* With no reachable global, `global` is left out and `collection` stays
|
|
71
75
|
* required, so a deployment without globals sees an unchanged schema. Only the
|
|
72
76
|
* mixed case makes either optional, and the handler enforces exclusivity there.
|
|
73
|
-
|
|
77
|
+
* `globalRule` states that rule on `global` for a tool without an `id`.
|
|
78
|
+
*/ const entityShape = (scope, operation, globalRule) => {
|
|
74
79
|
const { collections, globals } = slugsFor(scope, operation);
|
|
75
|
-
if (globals.length === 0) return widen({ collection: slugEnum(collections)
|
|
76
|
-
if (collections.length === 0) return widen({ global: slugEnum(globals)
|
|
80
|
+
if (globals.length === 0) return widen({ collection: slugEnum(collections) });
|
|
81
|
+
if (collections.length === 0) return widen({ global: slugEnum(globals) });
|
|
82
|
+
const global = slugEnum(globals).optional();
|
|
77
83
|
return widen({
|
|
78
|
-
collection: slugEnum(collections).optional()
|
|
79
|
-
global:
|
|
84
|
+
collection: slugEnum(collections).optional(),
|
|
85
|
+
global: globalRule === void 0 ? global : global.describe(globalRule)
|
|
80
86
|
});
|
|
81
87
|
};
|
|
82
88
|
/**
|
|
83
89
|
* Only a collection document has one. Optional in the mixed case, where
|
|
84
|
-
* `
|
|
90
|
+
* `resolveDocument` enforces the dependency and the description states it.
|
|
85
91
|
*/ const idShape = (scope, operation) => {
|
|
86
92
|
const { collections, globals } = slugsFor(scope, operation);
|
|
87
93
|
if (collections.length === 0) return widen({});
|
|
88
94
|
if (globals.length === 0) return widen({ id: idSchema });
|
|
89
|
-
return widen({ id: idSchema.optional().describe("
|
|
95
|
+
return widen({ id: idSchema.optional().describe("Required with \"collection\", omitted with \"global\".") });
|
|
90
96
|
};
|
|
97
|
+
/** For the reads that fall back to the default locale, unlike the writes. */ const READ_LOCALE_DESCRIPTION = "Default: the default locale. A value missing in this locale is shown from the default locale.";
|
|
91
98
|
const localeShape = (scope, options) => {
|
|
92
|
-
if (!scope.
|
|
93
|
-
const
|
|
94
|
-
|
|
99
|
+
if (!scope.localization) return widen({});
|
|
100
|
+
const enumerated = z.enum(scope.localization.locales);
|
|
101
|
+
const locale = options.required ? enumerated : enumerated.optional();
|
|
102
|
+
return widen({ locale: options.description === void 0 ? locale : locale.describe(options.description) });
|
|
95
103
|
};
|
|
96
104
|
/**
|
|
97
105
|
* Defaults to 0 rather than Payload's own default: a client usually wants ids
|
|
98
106
|
* it can write back, and populating a relation costs a query.
|
|
99
|
-
*/ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(
|
|
107
|
+
*/ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe("Default 0. A relationship into a collection this key cannot read stays an id.") });
|
|
100
108
|
/**
|
|
101
109
|
* The locale to operate on: the explicit argument, else the request's, else
|
|
102
110
|
* the default. `undefined` when localization is off.
|
|
103
111
|
*/ const localeOf = (scope, locale) => {
|
|
104
|
-
if (!scope.
|
|
112
|
+
if (!scope.localization) return;
|
|
113
|
+
const { locales, defaultLocale } = scope.localization;
|
|
105
114
|
const requested = locale ?? scope.req.locale;
|
|
106
|
-
return
|
|
107
|
-
};
|
|
108
|
-
/**
|
|
109
|
-
* Reads the current draft in a fixed locale with no fallback, which is the
|
|
110
|
-
* shape that may be written back or validated without mixing locales.
|
|
111
|
-
*/ const readTarget = async (scope, args) => {
|
|
112
|
-
const { payload } = scope.req;
|
|
113
|
-
const privileged = args.privileged === true;
|
|
114
|
-
const shared = {
|
|
115
|
-
depth: 0,
|
|
116
|
-
draft: true,
|
|
117
|
-
...args.locale === void 0 ? {} : {
|
|
118
|
-
locale: args.locale,
|
|
119
|
-
fallbackLocale: false
|
|
120
|
-
},
|
|
121
|
-
overrideAccess: privileged,
|
|
122
|
-
showHiddenFields: privileged,
|
|
123
|
-
req: scope.req
|
|
124
|
-
};
|
|
125
|
-
if (args.target.kind === "collection") {
|
|
126
|
-
const doc = await payload.findByID({
|
|
127
|
-
...shared,
|
|
128
|
-
collection: args.target.slug,
|
|
129
|
-
id: args.id,
|
|
130
|
-
disableErrors: true
|
|
131
|
-
});
|
|
132
|
-
if (!doc) throw new NotFound(scope.req.t);
|
|
133
|
-
return doc;
|
|
134
|
-
}
|
|
135
|
-
return await payload.findGlobal({
|
|
136
|
-
...shared,
|
|
137
|
-
slug: args.target.slug
|
|
138
|
-
});
|
|
139
|
-
};
|
|
140
|
-
const translateLabel = (scope, label, fallback) => {
|
|
141
|
-
const { i18n, t } = scope.req;
|
|
142
|
-
const resolved = typeof label === "function" ? label({
|
|
143
|
-
i18n,
|
|
144
|
-
t
|
|
145
|
-
}) : label;
|
|
146
|
-
return translateStatic(resolved, i18n) ?? fallback;
|
|
115
|
+
return requested && locales.includes(requested) ? requested : defaultLocale;
|
|
147
116
|
};
|
|
148
117
|
//#endregion
|
|
149
|
-
export { depthShape,
|
|
118
|
+
export { READ_LOCALE_DESCRIPTION, depthShape, draftVersionSlugs, entityShape, idSchema, idShape, liveWriteSentence, liveWriteSlugs, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor, widen };
|
|
@@ -1,48 +1,35 @@
|
|
|
1
|
+
import { defineMcpxTool } from "../define-tool.mjs";
|
|
1
2
|
import { jsonResult } from "../result.mjs";
|
|
2
|
-
import { idShape, localeOf, localeShape
|
|
3
|
-
import {
|
|
4
|
-
import { defineMcpxTool } from "../types.mjs";
|
|
3
|
+
import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
|
|
4
|
+
import { identityOf, readDraft, resolveDocument } from "./document.mjs";
|
|
5
5
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
6
6
|
/**
|
|
7
|
-
* Gated on write rather than read, because publish blockers only mean
|
|
8
|
-
*
|
|
7
|
+
* Gated on write rather than read, because publish blockers only mean something
|
|
8
|
+
* to a caller who can act on them.
|
|
9
9
|
*
|
|
10
10
|
* It reads the document twice on purpose: once under the key's own access to
|
|
11
|
-
* refuse a caller who may not see it, then privileged, so the check
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* refuse a caller who may not see it, then privileged, so the check covers every
|
|
12
|
+
* field and not only those the user can read. It has no `readOnlyHint` because
|
|
13
|
+
* the traversal fires field hooks.
|
|
14
14
|
*/ const validateDocument = defineMcpxTool({
|
|
15
15
|
name: "validateDocument",
|
|
16
|
-
description: `
|
|
17
|
-
|
|
18
|
-
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
19
|
-
|
|
20
|
-
Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
|
|
16
|
+
description: `Lists what still blocks publishing one document or global, without saving. Returns "publishBlockers", each with a pointer and a message. An empty list means it can be published, unless "publishBlockersUnavailable" is true: then the check itself failed. The check runs the field hooks a save runs.`,
|
|
21
17
|
annotations: { openWorldHint: false },
|
|
22
|
-
isEnabled: (scope) => scope.writable.length + scope.
|
|
18
|
+
isEnabled: (scope) => scope.collections.writable.length + scope.globals.writable.length > 0,
|
|
23
19
|
inputSchema: (scope) => ({
|
|
24
|
-
...
|
|
25
|
-
collection: "Collection holding the document.",
|
|
26
|
-
global: "Global to validate."
|
|
27
|
-
}),
|
|
20
|
+
...entityShape(scope, "write"),
|
|
28
21
|
...idShape(scope, "write"),
|
|
29
|
-
...localeShape(scope, {
|
|
30
|
-
required: true,
|
|
31
|
-
description: "Locale to validate."
|
|
32
|
-
})
|
|
22
|
+
...localeShape(scope, { required: true })
|
|
33
23
|
}),
|
|
34
24
|
handler: async ({ args, scope }) => {
|
|
35
|
-
const target =
|
|
36
|
-
const id = requireIdFor(target, args.id);
|
|
25
|
+
const target = resolveDocument(scope, args, "write");
|
|
37
26
|
const locale = localeOf(scope, args.locale);
|
|
38
|
-
await
|
|
27
|
+
await readDraft(scope, {
|
|
39
28
|
target,
|
|
40
|
-
id,
|
|
41
29
|
locale
|
|
42
30
|
});
|
|
43
|
-
const doc = await
|
|
31
|
+
const doc = await readDraft(scope, {
|
|
44
32
|
target,
|
|
45
|
-
id,
|
|
46
33
|
locale,
|
|
47
34
|
privileged: true
|
|
48
35
|
});
|
|
@@ -51,7 +38,7 @@ Nothing is written, but the check runs the same field-level beforeValidate and b
|
|
|
51
38
|
entity: target
|
|
52
39
|
});
|
|
53
40
|
return jsonResult({
|
|
54
|
-
...target
|
|
41
|
+
...identityOf(target, doc["id"]),
|
|
55
42
|
status: doc["_status"],
|
|
56
43
|
updatedAt: doc["updatedAt"],
|
|
57
44
|
publishBlockers: validation.blockers,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { stripAdminHidden } from "../schema/admin-hidden.mjs";
|
|
2
|
+
import "../schema/index.mjs";
|
|
3
|
+
import { mcpxReadRequest } from "./read-request.mjs";
|
|
4
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
5
|
+
import { Pointer, createPatch } from "rfc6902";
|
|
6
|
+
//#region src/tools/versions.ts
|
|
7
|
+
const NOISE = [
|
|
8
|
+
"id",
|
|
9
|
+
"globalType",
|
|
10
|
+
"createdAt",
|
|
11
|
+
"updatedAt",
|
|
12
|
+
"_status"
|
|
13
|
+
];
|
|
14
|
+
const NEWEST_FIRST = ["-updatedAt", "-id"];
|
|
15
|
+
/** The document or global as the key's user sees it, without `admin.hidden` fields. */ const readLive = async (scope, read, options) => {
|
|
16
|
+
const shared = {
|
|
17
|
+
depth: read.depth,
|
|
18
|
+
draft: options.draft,
|
|
19
|
+
overrideAccess: false,
|
|
20
|
+
req: mcpxReadRequest(scope),
|
|
21
|
+
...options.select === void 0 ? {} : { select: options.select },
|
|
22
|
+
...read.locale === void 0 ? {} : { locale: read.locale }
|
|
23
|
+
};
|
|
24
|
+
const doc = await (read.target.kind === "collection" ? scope.req.payload.findByID({
|
|
25
|
+
...shared,
|
|
26
|
+
collection: read.target.slug,
|
|
27
|
+
id: read.target.id
|
|
28
|
+
}) : scope.req.payload.findGlobal({
|
|
29
|
+
...shared,
|
|
30
|
+
slug: read.target.slug
|
|
31
|
+
}));
|
|
32
|
+
return stripAdminHidden(scope.req.payload.config, read.target, doc);
|
|
33
|
+
};
|
|
34
|
+
const withoutHidden = (scope, read, stored) => ({
|
|
35
|
+
...stored,
|
|
36
|
+
version: stripAdminHidden(scope.req.payload.config, read.target, stored.version)
|
|
37
|
+
});
|
|
38
|
+
const assertReadable = async (scope, read) => {
|
|
39
|
+
await readLive(scope, {
|
|
40
|
+
...read,
|
|
41
|
+
depth: 0
|
|
42
|
+
}, {
|
|
43
|
+
draft: true,
|
|
44
|
+
select: {}
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
/** `parent` comes back as a raw id or, populated, as the document itself. */ const isVersionOf = (version, id) => {
|
|
48
|
+
const parent = version["parent"];
|
|
49
|
+
const parentId = typeof parent === "object" && parent !== null ? parent["id"] : parent;
|
|
50
|
+
return (typeof parentId === "string" || typeof parentId === "number") && String(parentId) === String(id);
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Reads one version, or `null` when it does not exist, is not readable, or
|
|
54
|
+
* belongs to another document. `findVersionByID` knows nothing of the document
|
|
55
|
+
* the caller asked about, so the parent check is what keeps one readable
|
|
56
|
+
* document from opening the history of another.
|
|
57
|
+
*/ const loadVersion = async (scope, read, versionId) => {
|
|
58
|
+
await assertReadable(scope, read);
|
|
59
|
+
const { payload } = scope.req;
|
|
60
|
+
const shared = {
|
|
61
|
+
id: versionId,
|
|
62
|
+
depth: read.depth,
|
|
63
|
+
disableErrors: true,
|
|
64
|
+
overrideAccess: false,
|
|
65
|
+
req: mcpxReadRequest(scope),
|
|
66
|
+
...read.locale === void 0 ? {} : { locale: read.locale }
|
|
67
|
+
};
|
|
68
|
+
const version = await (read.target.kind === "collection" ? payload.findVersionByID({
|
|
69
|
+
...shared,
|
|
70
|
+
collection: read.target.slug
|
|
71
|
+
}) : payload.findGlobalVersionByID({
|
|
72
|
+
...shared,
|
|
73
|
+
slug: read.target.slug
|
|
74
|
+
}));
|
|
75
|
+
if (!version) return null;
|
|
76
|
+
if (read.target.kind === "collection" && !isVersionOf(version, read.target.id)) return null;
|
|
77
|
+
return withoutHidden(scope, read, version);
|
|
78
|
+
};
|
|
79
|
+
/** One page of a document's or global's history, newest first. */ const queryVersions = async (scope, read, options) => {
|
|
80
|
+
await assertReadable(scope, read);
|
|
81
|
+
const where = {
|
|
82
|
+
...read.target.kind === "collection" ? { parent: { equals: read.target.id } } : {},
|
|
83
|
+
...options.status === void 0 ? {} : { "version._status": { equals: options.status } }
|
|
84
|
+
};
|
|
85
|
+
const shared = {
|
|
86
|
+
depth: read.depth,
|
|
87
|
+
limit: options.limit,
|
|
88
|
+
sort: NEWEST_FIRST,
|
|
89
|
+
overrideAccess: false,
|
|
90
|
+
req: mcpxReadRequest(scope),
|
|
91
|
+
where,
|
|
92
|
+
...options.page === void 0 ? {} : { page: options.page },
|
|
93
|
+
...read.locale === void 0 ? {} : { locale: read.locale }
|
|
94
|
+
};
|
|
95
|
+
return await (read.target.kind === "collection" ? scope.req.payload.findVersions({
|
|
96
|
+
...shared,
|
|
97
|
+
collection: read.target.slug
|
|
98
|
+
}) : scope.req.payload.findGlobalVersions({
|
|
99
|
+
...shared,
|
|
100
|
+
slug: read.target.slug
|
|
101
|
+
}));
|
|
102
|
+
};
|
|
103
|
+
/** The newest version whose status is published, or `null` if none is. */ const loadPublished = async (scope, read) => {
|
|
104
|
+
if (!hasDraftsEnabled(read.target.config)) return null;
|
|
105
|
+
const [newest] = (await queryVersions(scope, read, {
|
|
106
|
+
status: "published",
|
|
107
|
+
limit: 1
|
|
108
|
+
})).docs;
|
|
109
|
+
return newest ? withoutHidden(scope, read, newest) : null;
|
|
110
|
+
};
|
|
111
|
+
const strip = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => !NOISE.includes(key)));
|
|
112
|
+
/**
|
|
113
|
+
* The RFC 6902 operations turning `from` into `to`, limited to the subtree at
|
|
114
|
+
* `pointer`. Op paths stay absolute, so they apply as `patchDocument` ops.
|
|
115
|
+
*/ const diffDocuments = (from, to, pointer = Pointer.fromJSON("")) => {
|
|
116
|
+
const wrap = (doc) => {
|
|
117
|
+
const value = pointer.get(strip(doc));
|
|
118
|
+
return value === void 0 ? {} : { value };
|
|
119
|
+
};
|
|
120
|
+
const path = pointer.toString();
|
|
121
|
+
return createPatch(wrap(from), wrap(to)).map((op) => ({
|
|
122
|
+
...op,
|
|
123
|
+
path: `${path}${op.path.slice(6)}`
|
|
124
|
+
}));
|
|
125
|
+
};
|
|
126
|
+
//#endregion
|
|
127
|
+
export { diffDocuments, isVersionOf, loadPublished, loadVersion, queryVersions, readLive };
|
package/dist/types.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DocumentId } from "./entity.mjs";
|
|
1
2
|
import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
@@ -17,32 +18,41 @@ declare module "payload" {
|
|
|
17
18
|
* - `false`: no write tool touches it.
|
|
18
19
|
* - `"draft"`: writes land as drafts and nothing MCP does changes what the
|
|
19
20
|
* public sees. Requires `versions.drafts`.
|
|
20
|
-
* - `"live"`: MCP may change live content. On an entity with drafts that
|
|
21
|
-
* `publishDocument
|
|
22
|
-
*
|
|
21
|
+
* - `"live"`: MCP may change live content. On an entity with drafts that
|
|
22
|
+
* exposes `publishDocument`. On one without, there is no draft, so the write
|
|
23
|
+
* itself is permitted and lands live.
|
|
23
24
|
*/
|
|
24
25
|
export type McpxWriteMode = "draft" | "live" | false;
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* What an entity exposes. The config names the entities that are reachable and
|
|
28
|
+
* only takes capabilities away; a key's checkboxes decide per key. `true` is
|
|
29
|
+
* shorthand for `{}`, which exposes everything the entity supports.
|
|
30
|
+
*/
|
|
26
31
|
export interface McpxCollectionOptions {
|
|
27
32
|
/** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
|
|
28
33
|
read?: boolean;
|
|
29
34
|
/**
|
|
30
35
|
* Expose `patchDocument`, `validateDocument` and, unless this is an upload
|
|
31
|
-
* collection, `createDocument
|
|
32
|
-
* `
|
|
36
|
+
* collection, `createDocument`. Without drafts a write changes live
|
|
37
|
+
* content. Default `true`.
|
|
33
38
|
*/
|
|
34
|
-
write?:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
write?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Expose `publishDocument`, which promotes a draft to live content. Needs
|
|
42
|
+
* `versions.drafts` and `write`. Default `true` where the entity has drafts.
|
|
43
|
+
* `false` keeps writes as drafts and is refused where there are none.
|
|
44
|
+
*/
|
|
45
|
+
publish?: boolean;
|
|
40
46
|
/**
|
|
41
|
-
* Expose `
|
|
42
|
-
*
|
|
47
|
+
* Expose `findVersions` and the `versionId` and `diffFrom` arguments of
|
|
48
|
+
* `getDocument` to keys that may read the entity. Default `false`: version
|
|
49
|
+
* history has no checkbox of its own and follows the key's read, so the
|
|
50
|
+
* config opts in. `true` needs Payload `versions` and `read`.
|
|
43
51
|
*/
|
|
44
|
-
|
|
52
|
+
versions?: boolean;
|
|
45
53
|
}
|
|
54
|
+
/** The same options. A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
|
|
55
|
+
export type McpxGlobalOptions = McpxCollectionOptions;
|
|
46
56
|
export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
47
57
|
/** What the config exposes, before an API key's checkboxes narrow it. */
|
|
48
58
|
export interface McpxExposedEntity {
|
|
@@ -50,24 +60,33 @@ export interface McpxExposedEntity {
|
|
|
50
60
|
read: boolean;
|
|
51
61
|
write: McpxWriteMode;
|
|
52
62
|
hasDrafts: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Payload keeps a version history, with or without drafts, and the config
|
|
65
|
+
* exposes it. Says nothing about drafts; see `hasDrafts`.
|
|
66
|
+
*/
|
|
67
|
+
hasVersions: boolean;
|
|
53
68
|
/** An upload document is a file, and no tool here can supply one. */
|
|
54
69
|
isUpload: boolean;
|
|
55
70
|
/** Name of the capability group on the key document. */
|
|
56
71
|
fieldName: string;
|
|
57
72
|
}
|
|
73
|
+
/** The slugs a key may read, write and publish. */
|
|
74
|
+
export interface McpxScopeSlugs {
|
|
75
|
+
readable: string[];
|
|
76
|
+
writable: string[];
|
|
77
|
+
publishable: string[];
|
|
78
|
+
}
|
|
58
79
|
/** What a tool knows about the current request. */
|
|
59
80
|
export interface McpxToolScope {
|
|
60
81
|
req: PayloadRequest;
|
|
61
82
|
capabilities: McpxResolvedCapabilities;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
publishable: string[];
|
|
65
|
-
readableGlobals: string[];
|
|
66
|
-
writableGlobals: string[];
|
|
67
|
-
publishableGlobals: string[];
|
|
83
|
+
collections: McpxScopeSlugs;
|
|
84
|
+
globals: McpxScopeSlugs;
|
|
68
85
|
/** `null` when localization is off. */
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
localization: null | {
|
|
87
|
+
locales: string[];
|
|
88
|
+
defaultLocale: string;
|
|
89
|
+
};
|
|
71
90
|
limits: {
|
|
72
91
|
maxLimit: number;
|
|
73
92
|
maxDepth: number;
|
|
@@ -85,7 +104,7 @@ export interface McpxToolScope {
|
|
|
85
104
|
export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
86
105
|
/** camelCase, unique, not one of the builtin tool names. */
|
|
87
106
|
name: string;
|
|
88
|
-
/** Built per request so it can state what this key's writes
|
|
107
|
+
/** Built per request so it can state what this key's writes do. */
|
|
89
108
|
description: string | ((scope: McpxToolScope) => string);
|
|
90
109
|
annotations?: ToolAnnotations;
|
|
91
110
|
/**
|
|
@@ -97,7 +116,7 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
|
|
|
97
116
|
/**
|
|
98
117
|
* Built per request so enums can be narrowed to what the key may touch.
|
|
99
118
|
* Registered strictly either way: an unknown argument is rejected by name
|
|
100
|
-
*
|
|
119
|
+
* instead of stripped.
|
|
101
120
|
*/
|
|
102
121
|
inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
|
|
103
122
|
handler(ctx: {
|
|
@@ -110,25 +129,10 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
|
|
|
110
129
|
}
|
|
111
130
|
/** Argument type erased, so a registry can hold tools of differing shapes. */
|
|
112
131
|
export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
|
|
113
|
-
/** Fixed shape; arguments inferred from it. */
|
|
114
|
-
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
115
|
-
inputSchema?: Shape;
|
|
116
|
-
}): McpxTool<Shape>;
|
|
117
|
-
/** Per-request shape returned as an object literal; arguments inferred from it. */
|
|
118
|
-
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
119
|
-
inputSchema: (scope: McpxToolScope) => Shape;
|
|
120
|
-
}): McpxAnyTool;
|
|
121
|
-
/**
|
|
122
|
-
* Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
|
|
123
|
-
* builtins do. Nothing to infer from, so state the arguments instead.
|
|
124
|
-
*/
|
|
125
|
-
export declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
|
|
126
|
-
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
127
|
-
}): McpxAnyTool;
|
|
128
132
|
export interface McpxAuthResult {
|
|
129
133
|
/** Must carry `collection`. */
|
|
130
134
|
user: TypedUser;
|
|
131
|
-
apiKeyId:
|
|
135
|
+
apiKeyId: DocumentId;
|
|
132
136
|
/** The `capabilities` group as stored on the key document. */
|
|
133
137
|
capabilities: unknown;
|
|
134
138
|
}
|
|
@@ -139,9 +143,9 @@ export interface McpxAuthResult {
|
|
|
139
143
|
* to `Record<string, unknown>`, which interfaces do not satisfy.
|
|
140
144
|
*/
|
|
141
145
|
export type McpxPluginOptions = {
|
|
142
|
-
/** Allow-list of collections. `true` is shorthand for `{
|
|
146
|
+
/** Allow-list of collections. `true` is shorthand for `{}`. */
|
|
143
147
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
144
|
-
/** Allow-list of globals. `true` is shorthand for `{
|
|
148
|
+
/** Allow-list of globals. `true` is shorthand for `{}`. */
|
|
145
149
|
globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
|
|
146
150
|
/** Collection the keys act as. Default `config.admin.user`, then `users`. */
|
|
147
151
|
userCollection?: CollectionSlug;
|
|
@@ -168,7 +172,11 @@ export type McpxPluginOptions = {
|
|
|
168
172
|
};
|
|
169
173
|
tools?: McpxAnyTool[];
|
|
170
174
|
auth?: {
|
|
171
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Replace or wrap the default key resolution. Return `null` for 401. A
|
|
177
|
+
* result whose user has no `id` or whose `collection` is not the user
|
|
178
|
+
* collection, or that has no `apiKeyId`, is also answered with 401.
|
|
179
|
+
*/
|
|
172
180
|
resolve?: (args: {
|
|
173
181
|
req: PayloadRequest;
|
|
174
182
|
resolveDefault: () => Promise<McpxAuthResult | null>;
|
|
@@ -183,10 +191,10 @@ export type McpxPluginOptions = {
|
|
|
183
191
|
export interface McpxCollectionCapabilities {
|
|
184
192
|
read: boolean;
|
|
185
193
|
write: boolean;
|
|
186
|
-
/** Only ever true where the config
|
|
194
|
+
/** Only ever true where the config lets writes publish and drafts exist. */
|
|
187
195
|
publish: boolean;
|
|
188
196
|
}
|
|
189
|
-
/** In force for one request: plugin config
|
|
197
|
+
/** In force for one request: the plugin config and the key checkboxes together. */
|
|
190
198
|
export interface McpxResolvedCapabilities {
|
|
191
199
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
192
200
|
globals: Record<string, McpxCollectionCapabilities>;
|
|
@@ -194,15 +202,7 @@ export interface McpxResolvedCapabilities {
|
|
|
194
202
|
}
|
|
195
203
|
/** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
|
|
196
204
|
export interface McpxRequestContext {
|
|
197
|
-
apiKeyId:
|
|
205
|
+
apiKeyId: DocumentId;
|
|
198
206
|
capabilities: McpxResolvedCapabilities;
|
|
199
207
|
}
|
|
200
|
-
/** One reason a human could not publish the draft as it stands. */
|
|
201
|
-
export interface PublishBlocker {
|
|
202
|
-
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
203
|
-
field?: string;
|
|
204
|
-
message: string;
|
|
205
|
-
/** JSON Pointer to the offending value, e.g. "/layout/2/title". */
|
|
206
|
-
path: string;
|
|
207
|
-
}
|
|
208
208
|
//#endregion
|