@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,68 @@
|
|
|
1
|
+
import { translatorFor } from "../i18n.mjs";
|
|
2
|
+
import { jsonResult } from "../endpoint/result.mjs";
|
|
3
|
+
import { translateLabel } from "./shared.mjs";
|
|
4
|
+
import { hasDraftValidationEnabled } from "payload/shared";
|
|
5
|
+
//#region src/tools/list-capabilities.ts
|
|
6
|
+
const listCapabilities = {
|
|
7
|
+
name: "listCapabilities",
|
|
8
|
+
description: `Lists what this key may do: the collections and globals it can read or write, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
|
|
9
|
+
|
|
10
|
+
A global is a singleton: it has no id, is not listed by findDocuments and cannot be created. Address one with the "global" argument where a collection document would take "collection" and "id".`,
|
|
11
|
+
annotations: {
|
|
12
|
+
readOnlyHint: true,
|
|
13
|
+
openWorldHint: false
|
|
14
|
+
},
|
|
15
|
+
isEnabled: () => true,
|
|
16
|
+
inputSchema: () => ({}),
|
|
17
|
+
handler: (_args, scope) => {
|
|
18
|
+
const { payload } = scope.req;
|
|
19
|
+
const translate = translatorFor(scope.req.i18n);
|
|
20
|
+
const collections = scope.options.collections.flatMap((entry) => {
|
|
21
|
+
const capability = scope.capabilities.collections[entry.slug];
|
|
22
|
+
const collection = payload.collections[entry.slug];
|
|
23
|
+
if (!capability || !collection || !(capability.read || capability.write)) return [];
|
|
24
|
+
const { config } = collection;
|
|
25
|
+
const description = translate(config.admin.description);
|
|
26
|
+
return [{
|
|
27
|
+
slug: entry.slug,
|
|
28
|
+
labels: {
|
|
29
|
+
singular: translateLabel(scope, config.labels.singular, entry.slug),
|
|
30
|
+
plural: translateLabel(scope, config.labels.plural, entry.slug)
|
|
31
|
+
},
|
|
32
|
+
...description === void 0 ? {} : { description },
|
|
33
|
+
read: capability.read,
|
|
34
|
+
write: capability.write,
|
|
35
|
+
drafts: entry.hasDrafts,
|
|
36
|
+
draftValidation: hasDraftValidationEnabled(config),
|
|
37
|
+
idType: collection.customIDType ?? payload.db.defaultIDType
|
|
38
|
+
}];
|
|
39
|
+
});
|
|
40
|
+
const globals = scope.options.globals.flatMap((entry) => {
|
|
41
|
+
const capability = scope.capabilities.globals[entry.slug];
|
|
42
|
+
const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
|
|
43
|
+
if (!capability || !config || !(capability.read || capability.write)) return [];
|
|
44
|
+
const description = translate(config.admin.description);
|
|
45
|
+
return [{
|
|
46
|
+
slug: entry.slug,
|
|
47
|
+
label: translateLabel(scope, config.label, entry.slug),
|
|
48
|
+
...description === void 0 ? {} : { description },
|
|
49
|
+
read: capability.read,
|
|
50
|
+
write: capability.write,
|
|
51
|
+
drafts: entry.hasDrafts,
|
|
52
|
+
draftValidation: hasDraftValidationEnabled(config)
|
|
53
|
+
}];
|
|
54
|
+
});
|
|
55
|
+
return Promise.resolve(jsonResult({
|
|
56
|
+
collections,
|
|
57
|
+
...globals.length > 0 ? { globals } : {},
|
|
58
|
+
locales: scope.locales ? {
|
|
59
|
+
codes: scope.locales,
|
|
60
|
+
default: scope.defaultLocale
|
|
61
|
+
} : null,
|
|
62
|
+
limits: scope.options.limits,
|
|
63
|
+
tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
export { listCapabilities };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/tools/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
|
+
"patchDocument",
|
|
10
|
+
"createDocument",
|
|
11
|
+
"validateDocument"
|
|
12
|
+
];
|
|
13
|
+
//#endregion
|
|
14
|
+
export { BUILTIN_TOOL_NAMES };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { errorResult, jsonResult } from "../endpoint/result.mjs";
|
|
2
|
+
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
|
+
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
|
+
import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
|
|
5
|
+
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
6
|
+
import { withTransaction } from "../write/transaction.mjs";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { Pointer } from "rfc6902";
|
|
9
|
+
//#region src/tools/patch-document.ts
|
|
10
|
+
const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
|
|
11
|
+
|
|
12
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
13
|
+
|
|
14
|
+
The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
|
|
15
|
+
|
|
16
|
+
Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
|
|
17
|
+
|
|
18
|
+
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so a concurrent edit is refused rather than overwritten.
|
|
19
|
+
|
|
20
|
+
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but a human cannot publish the document until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update.`;
|
|
21
|
+
const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
22
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
/**
|
|
24
|
+
* Whether the intended value survived the write. The saved document is
|
|
25
|
+
* allowed to carry more than was sent: Payload assigns fresh row ids and
|
|
26
|
+
* backfills defaults and nulls on save, so `id` keys are ignored and only
|
|
27
|
+
* the keys the client sent are compared. Null and absent count as equal.
|
|
28
|
+
*/ const survives = (expected, actual) => {
|
|
29
|
+
if (expected === void 0 || expected === null) return actual === void 0 || actual === null;
|
|
30
|
+
if (Array.isArray(expected)) return Array.isArray(actual) && expected.length === actual.length && expected.every((entry, index) => survives(entry, actual[index]));
|
|
31
|
+
if (isPlainObject(expected)) return isPlainObject(actual) && Object.entries(expected).every(([key, value]) => key === "id" || survives(value, actual[key]));
|
|
32
|
+
return isPlainObject(actual) || Array.isArray(actual) ? false : JSON.stringify(expected) === JSON.stringify(actual);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Pointers whose intended value did not survive the write. Element pointers
|
|
36
|
+
* are skipped: an append pointer (`/-`) does not resolve against the saved
|
|
37
|
+
* document.
|
|
38
|
+
*/ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operation) => {
|
|
39
|
+
if (operation.op !== "add" && operation.op !== "replace" || isElementPointer(operation.path)) return [];
|
|
40
|
+
const pointer = Pointer.fromJSON(operation.path);
|
|
41
|
+
const expected = pointer.get(intended);
|
|
42
|
+
const actual = pointer.get(saved);
|
|
43
|
+
return survives(expected, actual) ? [] : [operation.path];
|
|
44
|
+
});
|
|
45
|
+
const patchDocument = {
|
|
46
|
+
name: "patchDocument",
|
|
47
|
+
description: DESCRIPTION,
|
|
48
|
+
annotations: {
|
|
49
|
+
readOnlyHint: false,
|
|
50
|
+
destructiveHint: false,
|
|
51
|
+
idempotentHint: false,
|
|
52
|
+
openWorldHint: false
|
|
53
|
+
},
|
|
54
|
+
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
55
|
+
inputSchema: (scope) => ({
|
|
56
|
+
...targetShape(scope, "write", {
|
|
57
|
+
collection: "Collection holding the document.",
|
|
58
|
+
global: "Global to patch."
|
|
59
|
+
}),
|
|
60
|
+
...idShape(scope, "write"),
|
|
61
|
+
...localeShape(scope, {
|
|
62
|
+
required: true,
|
|
63
|
+
description: "Locale the patch applies to. Localized fields write here only."
|
|
64
|
+
}),
|
|
65
|
+
patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
|
|
66
|
+
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
|
|
67
|
+
}),
|
|
68
|
+
handler: async (args, scope) => {
|
|
69
|
+
const target = resolveTarget(scope, args, "write");
|
|
70
|
+
const id = requireIdFor(target, args.id);
|
|
71
|
+
const { payload } = scope.req;
|
|
72
|
+
const locale = localeOf(scope, args.locale);
|
|
73
|
+
return await withTransaction(scope.req, async () => {
|
|
74
|
+
const doc = await readTarget(scope, {
|
|
75
|
+
target,
|
|
76
|
+
id,
|
|
77
|
+
locale
|
|
78
|
+
});
|
|
79
|
+
if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again and re-apply the patch.", { updatedAt: doc["updatedAt"] });
|
|
80
|
+
const problems = findPatchProblems(payload.config, {
|
|
81
|
+
doc,
|
|
82
|
+
patches: args.patches,
|
|
83
|
+
ref: refOf(target)
|
|
84
|
+
});
|
|
85
|
+
if (problems.length > 0) return errorResult("No operation was applied.", { problems });
|
|
86
|
+
const applied = applyPatchToCopy(doc, args.patches);
|
|
87
|
+
if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
|
|
88
|
+
const write = {
|
|
89
|
+
data: buildWriteData(payload.config, target.config, applied.next),
|
|
90
|
+
depth: 0,
|
|
91
|
+
draft: true,
|
|
92
|
+
overrideAccess: false,
|
|
93
|
+
req: scope.req,
|
|
94
|
+
...locale === void 0 ? {} : { locale }
|
|
95
|
+
};
|
|
96
|
+
if (target.kind === "collection") await payload.update({
|
|
97
|
+
...write,
|
|
98
|
+
collection: target.slug,
|
|
99
|
+
id
|
|
100
|
+
});
|
|
101
|
+
else await payload.updateGlobal({
|
|
102
|
+
...write,
|
|
103
|
+
slug: target.slug
|
|
104
|
+
});
|
|
105
|
+
const saved = await readTarget(scope, {
|
|
106
|
+
target,
|
|
107
|
+
id,
|
|
108
|
+
locale,
|
|
109
|
+
privileged: true
|
|
110
|
+
});
|
|
111
|
+
const notApplied = notAppliedPointers(args.patches, applied.next, saved);
|
|
112
|
+
const publishBlockers = await collectPublishBlockers(scope.req, {
|
|
113
|
+
doc: saved,
|
|
114
|
+
entity: target
|
|
115
|
+
});
|
|
116
|
+
return jsonResult({
|
|
117
|
+
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
118
|
+
status: saved["_status"],
|
|
119
|
+
updatedAt: saved["updatedAt"],
|
|
120
|
+
...publishBlockers.length > 0 ? { publishBlockers } : {},
|
|
121
|
+
...notApplied.length > 0 ? { notApplied } : {}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
//#endregion
|
|
127
|
+
export { patchDocument };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { translateStatic } from "../i18n.mjs";
|
|
2
|
+
import { NotFound } from "payload";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/tools/shared.ts
|
|
5
|
+
const slugEnum = (slugs) => z.enum(slugs);
|
|
6
|
+
const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
7
|
+
const slugsFor = (scope, operation) => ({
|
|
8
|
+
collections: operation === "read" ? scope.readable : scope.writable,
|
|
9
|
+
globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* The `collection` and `global` arguments.
|
|
13
|
+
*
|
|
14
|
+
* When the key can reach no global, `global` is left out of the shape entirely
|
|
15
|
+
* and `collection` stays required, mirroring how {@link localeShape} omits
|
|
16
|
+
* `locale` when localization is off. A deployment without globals therefore
|
|
17
|
+
* sees exactly the schema it saw before. Only the mixed case makes either
|
|
18
|
+
* argument optional, and the handler enforces the exclusivity there.
|
|
19
|
+
*/ const targetShape = (scope, operation, descriptions) => {
|
|
20
|
+
const { collections, globals } = slugsFor(scope, operation);
|
|
21
|
+
if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
|
|
22
|
+
if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
|
|
23
|
+
return {
|
|
24
|
+
collection: slugEnum(collections).optional().describe(descriptions.collection),
|
|
25
|
+
global: slugEnum(globals).optional().describe(descriptions.global)
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The `id` argument, which only a collection document has. Omitted when the key
|
|
30
|
+
* can reach no collection, required when it can reach no global, and optional
|
|
31
|
+
* in between, where `requireIdFor` enforces the dependency.
|
|
32
|
+
*/ const idShape = (scope, operation) => {
|
|
33
|
+
const { collections, globals } = slugsFor(scope, operation);
|
|
34
|
+
if (collections.length === 0) return {};
|
|
35
|
+
if (globals.length === 0) return { id: idSchema };
|
|
36
|
+
return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The `locale` argument, present only when localization is configured.
|
|
40
|
+
*/ const localeShape = (scope, options) => {
|
|
41
|
+
if (!scope.locales) return {};
|
|
42
|
+
const locale = z.enum(scope.locales);
|
|
43
|
+
return { locale: (options.required ? locale : locale.optional()).describe(options.description) };
|
|
44
|
+
};
|
|
45
|
+
const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.options.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.options.limits.maxDepth)}.`) });
|
|
46
|
+
/**
|
|
47
|
+
* The locale to operate on: the explicit argument, else the request's, else
|
|
48
|
+
* the default. `undefined` when localization is off.
|
|
49
|
+
*/ const localeOf = (scope, locale) => {
|
|
50
|
+
if (!scope.locales) return;
|
|
51
|
+
const requested = locale ?? scope.req.locale;
|
|
52
|
+
return (requested && scope.locales.includes(requested) ? requested : scope.defaultLocale) ?? void 0;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Reads the current draft in a fixed locale with no fallback, which is the
|
|
56
|
+
* shape that may be written back or validated without mixing locales.
|
|
57
|
+
*/ const readTarget = async (scope, args) => {
|
|
58
|
+
const { payload } = scope.req;
|
|
59
|
+
const privileged = args.privileged === true;
|
|
60
|
+
const shared = {
|
|
61
|
+
depth: 0,
|
|
62
|
+
draft: true,
|
|
63
|
+
...args.locale === void 0 ? {} : {
|
|
64
|
+
locale: args.locale,
|
|
65
|
+
fallbackLocale: false
|
|
66
|
+
},
|
|
67
|
+
overrideAccess: privileged,
|
|
68
|
+
showHiddenFields: privileged,
|
|
69
|
+
req: scope.req
|
|
70
|
+
};
|
|
71
|
+
if (args.target.kind === "collection") {
|
|
72
|
+
const doc = await payload.findByID({
|
|
73
|
+
...shared,
|
|
74
|
+
collection: args.target.slug,
|
|
75
|
+
id: args.id,
|
|
76
|
+
disableErrors: true
|
|
77
|
+
});
|
|
78
|
+
if (!doc) throw new NotFound(scope.req.t);
|
|
79
|
+
return doc;
|
|
80
|
+
}
|
|
81
|
+
return await payload.findGlobal({
|
|
82
|
+
...shared,
|
|
83
|
+
slug: args.target.slug
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Resolves a collection label for the request's language.
|
|
88
|
+
*/ const translateLabel = (scope, label, fallback) => {
|
|
89
|
+
const { i18n, t } = scope.req;
|
|
90
|
+
const resolved = typeof label === "function" ? label({
|
|
91
|
+
i18n,
|
|
92
|
+
t
|
|
93
|
+
}) : label;
|
|
94
|
+
return translateStatic(resolved, i18n) ?? fallback;
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { APIError, Forbidden } from "payload";
|
|
2
|
+
//#region src/tools/target.ts
|
|
3
|
+
const refOf = (target) => ({
|
|
4
|
+
kind: target.kind,
|
|
5
|
+
slug: target.slug
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the `collection`/`global` arguments to one entity and checks the key
|
|
9
|
+
* may perform `operation` on it.
|
|
10
|
+
*
|
|
11
|
+
* A tool's `inputSchema` returns a raw shape, which leaves no top-level
|
|
12
|
+
* `.refine` to express "exactly one of collection and global". The rule is
|
|
13
|
+
* enforced here instead, with a message naming the offending arguments so one
|
|
14
|
+
* failed call teaches it.
|
|
15
|
+
*/ const resolveTarget = (scope, args, operation) => {
|
|
16
|
+
const { collection, global } = args;
|
|
17
|
+
if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
|
|
18
|
+
if (collection === void 0 && global === void 0) throw new APIError("One of \"collection\" or \"global\" is required. Call listCapabilities to see which slugs are available.", 400);
|
|
19
|
+
if (collection !== void 0) {
|
|
20
|
+
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
21
|
+
const found = scope.req.payload.collections[collection];
|
|
22
|
+
if (!allowed.includes(collection) || !found) throw new Forbidden(scope.req.t);
|
|
23
|
+
return {
|
|
24
|
+
kind: "collection",
|
|
25
|
+
slug: collection,
|
|
26
|
+
config: found.config
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const slug = global;
|
|
30
|
+
const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
|
|
31
|
+
const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
|
|
32
|
+
if (!allowed.includes(slug) || !found) throw new Forbidden(scope.req.t);
|
|
33
|
+
return {
|
|
34
|
+
kind: "global",
|
|
35
|
+
slug,
|
|
36
|
+
config: found
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Checks `id` against the resolved target. A collection document needs one; a
|
|
41
|
+
* global is a singleton and must not carry one. The schema cannot express the
|
|
42
|
+
* dependency, so it is stated here and in every affected tool description.
|
|
43
|
+
*/ const requireIdFor = (target, id) => {
|
|
44
|
+
if (target.kind === "collection" && id === void 0) throw new APIError(`"id" is required when "collection" is "${target.slug}".`, 400);
|
|
45
|
+
if (target.kind === "global" && id !== void 0) throw new APIError(`"id" must be omitted when "global" is "${target.slug}"; a global is a singleton.`, 400);
|
|
46
|
+
return target.kind === "collection" ? id : void 0;
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { refOf, requireIdFor, resolveTarget };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsonResult } from "../endpoint/result.mjs";
|
|
2
|
+
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
|
+
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
|
+
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
5
|
+
//#region src/tools/validate-document.ts
|
|
6
|
+
const validateDocument = {
|
|
7
|
+
name: "validateDocument",
|
|
8
|
+
description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
|
|
9
|
+
|
|
10
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.`,
|
|
11
|
+
annotations: {
|
|
12
|
+
readOnlyHint: true,
|
|
13
|
+
openWorldHint: false
|
|
14
|
+
},
|
|
15
|
+
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
16
|
+
inputSchema: (scope) => ({
|
|
17
|
+
...targetShape(scope, "write", {
|
|
18
|
+
collection: "Collection holding the document.",
|
|
19
|
+
global: "Global to validate."
|
|
20
|
+
}),
|
|
21
|
+
...idShape(scope, "write"),
|
|
22
|
+
...localeShape(scope, {
|
|
23
|
+
required: true,
|
|
24
|
+
description: "Locale to validate."
|
|
25
|
+
})
|
|
26
|
+
}),
|
|
27
|
+
handler: async (args, scope) => {
|
|
28
|
+
const target = resolveTarget(scope, args, "write");
|
|
29
|
+
const id = requireIdFor(target, args.id);
|
|
30
|
+
const locale = localeOf(scope, args.locale);
|
|
31
|
+
await readTarget(scope, {
|
|
32
|
+
target,
|
|
33
|
+
id,
|
|
34
|
+
locale
|
|
35
|
+
});
|
|
36
|
+
const doc = await readTarget(scope, {
|
|
37
|
+
target,
|
|
38
|
+
id,
|
|
39
|
+
locale,
|
|
40
|
+
privileged: true
|
|
41
|
+
});
|
|
42
|
+
const publishBlockers = await collectPublishBlockers(scope.req, {
|
|
43
|
+
doc,
|
|
44
|
+
entity: target
|
|
45
|
+
});
|
|
46
|
+
return jsonResult({
|
|
47
|
+
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
48
|
+
status: doc["_status"],
|
|
49
|
+
updatedAt: doc["updatedAt"],
|
|
50
|
+
publishBlockers
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { validateDocument };
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
4
|
+
import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
//#region src/types.d.ts
|
|
6
|
+
declare module "payload" {
|
|
7
|
+
interface RequestContext {
|
|
8
|
+
mcpx?: McpxRequestContext;
|
|
9
|
+
}
|
|
10
|
+
interface RegisteredPlugins {
|
|
11
|
+
"@abinnovision/payloadcms-mcpx": McpxPluginOptions;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* What an exposed collection offers to MCP clients. A key can only enable
|
|
16
|
+
* what the config exposes here.
|
|
17
|
+
*/
|
|
18
|
+
interface McpxCollectionOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Expose `describeSchema`, `findDocuments` and `getDocument`. Default `true`.
|
|
21
|
+
*/
|
|
22
|
+
read?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Expose `patchDocument`, `createDocument` and `validateDocument`. Default
|
|
25
|
+
* `false`. Requires `versions.drafts` unless `allowLiveWrites` is set.
|
|
26
|
+
*/
|
|
27
|
+
write?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Permit writes to a collection without drafts. Such writes land on the live
|
|
30
|
+
* document because there is no draft to land on. Default `false`.
|
|
31
|
+
*/
|
|
32
|
+
allowLiveWrites?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What an exposed global offers to MCP clients. Structurally the same as
|
|
36
|
+
* {@link McpxCollectionOptions}, kept separate because the tools it names
|
|
37
|
+
* differ: a global is a singleton, so neither `findDocuments` nor
|
|
38
|
+
* `createDocument` reaches one.
|
|
39
|
+
*/
|
|
40
|
+
interface McpxGlobalOptions {
|
|
41
|
+
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
42
|
+
read?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Expose `patchDocument` and `validateDocument`. Default `false`. Requires
|
|
45
|
+
* `versions.drafts` unless `allowLiveWrites` is set.
|
|
46
|
+
*/
|
|
47
|
+
write?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Permit writes to a global without drafts. Such writes land on the live
|
|
50
|
+
* document because there is no draft to land on. Default `false`.
|
|
51
|
+
*/
|
|
52
|
+
allowLiveWrites?: boolean;
|
|
53
|
+
}
|
|
54
|
+
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
55
|
+
/**
|
|
56
|
+
* A custom tool. It is gated by its own checkbox on every API key and runs
|
|
57
|
+
* with `req.user` resolved from the key and `req.context.mcpx` set.
|
|
58
|
+
*/
|
|
59
|
+
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape> {
|
|
60
|
+
/** camelCase, unique, not one of the builtin tool names. */
|
|
61
|
+
name: string;
|
|
62
|
+
description: string;
|
|
63
|
+
inputSchema?: Shape;
|
|
64
|
+
annotations?: ToolAnnotations;
|
|
65
|
+
handler(ctx: {
|
|
66
|
+
args: z.infer<z.ZodObject<Shape>>;
|
|
67
|
+
req: PayloadRequest;
|
|
68
|
+
extra: McpxToolExtra;
|
|
69
|
+
}): CallToolResult | Promise<CallToolResult>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Identity helper that infers the argument type of a custom tool's handler
|
|
73
|
+
* from its input schema.
|
|
74
|
+
*/
|
|
75
|
+
declare const defineMcpxTool: <Shape extends z.ZodRawShape>(tool: McpxTool<Shape>) => McpxTool<Shape>;
|
|
76
|
+
/**
|
|
77
|
+
* Outcome of resolving an API key. `user` must carry `collection`.
|
|
78
|
+
*/
|
|
79
|
+
interface McpxAuthResult {
|
|
80
|
+
user: TypedUser;
|
|
81
|
+
apiKeyId: number | string;
|
|
82
|
+
/** The `capabilities` group as stored on the key document. */
|
|
83
|
+
capabilities: unknown;
|
|
84
|
+
}
|
|
85
|
+
type McpxPluginOptions = {
|
|
86
|
+
/** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
|
|
87
|
+
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
88
|
+
/** Allow-list of globals. `true` is shorthand for `{ read: true }`. */
|
|
89
|
+
globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
|
|
90
|
+
/** Collection the keys act as. Default `config.admin.user`, then `users`. */
|
|
91
|
+
userCollection?: CollectionSlug;
|
|
92
|
+
apiKeys?: {
|
|
93
|
+
/** Slug of the generated API key collection. Default `mcpx-api-keys`. */
|
|
94
|
+
slug?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
|
|
97
|
+
* client config. Default `true`. The snippets contain the key in full.
|
|
98
|
+
*/
|
|
99
|
+
setupGuide?: boolean;
|
|
100
|
+
/** Final override applied to the generated collection. */
|
|
101
|
+
overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
|
|
102
|
+
};
|
|
103
|
+
endpoint?: {
|
|
104
|
+
/** Endpoint path below the API route. Default `/mcpx`. */
|
|
105
|
+
path?: string;
|
|
106
|
+
};
|
|
107
|
+
limits?: {
|
|
108
|
+
/** Upper bound for `findDocuments.limit`. Default 25. */
|
|
109
|
+
maxLimit?: number;
|
|
110
|
+
/** Upper bound for `depth` on reads. Default 1. */
|
|
111
|
+
maxDepth?: number;
|
|
112
|
+
};
|
|
113
|
+
tools?: McpxTool[];
|
|
114
|
+
auth?: {
|
|
115
|
+
/** Replace or wrap the default key resolution. Return `null` for 401. */
|
|
116
|
+
resolve?: (args: {
|
|
117
|
+
req: PayloadRequest;
|
|
118
|
+
resolveDefault: () => Promise<McpxAuthResult | null>;
|
|
119
|
+
}) => Promise<McpxAuthResult | null>;
|
|
120
|
+
};
|
|
121
|
+
serverInfo?: {
|
|
122
|
+
name?: string;
|
|
123
|
+
version?: string;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
interface McpxCollectionCapabilities {
|
|
127
|
+
read: boolean;
|
|
128
|
+
write: boolean;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Capabilities in force for one request: plugin config AND key checkboxes.
|
|
132
|
+
*/
|
|
133
|
+
interface McpxResolvedCapabilities {
|
|
134
|
+
collections: Record<string, McpxCollectionCapabilities>;
|
|
135
|
+
globals: Record<string, McpxCollectionCapabilities>;
|
|
136
|
+
tools: Record<string, boolean>;
|
|
137
|
+
}
|
|
138
|
+
interface McpxRequestContext {
|
|
139
|
+
apiKeyId: number | string;
|
|
140
|
+
capabilities: McpxResolvedCapabilities;
|
|
141
|
+
}
|
|
142
|
+
//#endregion
|
|
143
|
+
export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
|
package/dist/types.mjs
ADDED
package/dist/version.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { CollectionConfig, PayloadRequest } from "payload";
|
|
2
|
+
//#region src/write/draft-guard.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Whether a request originated from the MCP endpoint. The endpoint stamps
|
|
5
|
+
* `req.context.mcpx`, which travels into every local API call made with the
|
|
6
|
+
* same `req`, including those made by custom tools.
|
|
7
|
+
*/
|
|
8
|
+
declare const isMcpxRequest: (req: PayloadRequest) => boolean;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { isMcpxRequest };
|