@abinnovision/payloadcms-mcpx 1.0.0-beta.3
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 +258 -0
- package/dist/api-keys/collection.mjs +58 -0
- package/dist/api-keys/fields.mjs +88 -0
- package/dist/api-keys/key.mjs +10 -0
- package/dist/auth/resolve.mjs +62 -0
- package/dist/capabilities.mjs +30 -0
- package/dist/endpoint/handler.mjs +84 -0
- package/dist/endpoint/result.mjs +59 -0
- package/dist/endpoint/server.mjs +52 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +4 -0
- package/dist/options.mjs +107 -0
- package/dist/plugin.d.mts +9 -0
- package/dist/plugin.mjs +42 -0
- package/dist/schema/describe.mjs +82 -0
- package/dist/schema/lexical.mjs +25 -0
- package/dist/schema/pointer.mjs +83 -0
- package/dist/schema/shape.mjs +147 -0
- package/dist/schema/walk.mjs +109 -0
- package/dist/tools/create-document.mjs +67 -0
- package/dist/tools/describe-schema.mjs +44 -0
- package/dist/tools/find-documents.mjs +54 -0
- package/dist/tools/get-document.mjs +54 -0
- package/dist/tools/index.mjs +23 -0
- package/dist/tools/list-capabilities.mjs +49 -0
- package/dist/tools/names.mjs +14 -0
- package/dist/tools/patch-document.mjs +113 -0
- package/dist/tools/shared.mjs +65 -0
- package/dist/tools/validate-document.mjs +48 -0
- package/dist/types.d.mts +113 -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 +70 -0
- package/dist/write/patch.mjs +220 -0
- package/dist/write/publish-blockers.d.mts +13 -0
- package/dist/write/publish-blockers.mjs +72 -0
- package/dist/write/transaction.mjs +19 -0
- package/package.json +89 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { errorResult, jsonResult } from "../endpoint/result.mjs";
|
|
2
|
+
import { collectionEnum, ensureAllowed, idSchema, localeOf, localeShape, readDraft } from "./shared.mjs";
|
|
3
|
+
import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
|
|
4
|
+
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
5
|
+
import { withTransaction } from "../write/transaction.mjs";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { Pointer } from "rfc6902";
|
|
8
|
+
//#region src/tools/patch-document.ts
|
|
9
|
+
const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
|
|
10
|
+
|
|
11
|
+
The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
|
|
12
|
+
|
|
13
|
+
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. Use describeSchema to find a field's path, then turn it into a pointer by replacing "." with "/" and each "[]" with a 0-based index.
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
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.`;
|
|
18
|
+
const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
19
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
/**
|
|
21
|
+
* Whether the intended value survived the write. The saved document is
|
|
22
|
+
* allowed to carry more than was sent: Payload assigns fresh row ids and
|
|
23
|
+
* backfills defaults and nulls on save, so `id` keys are ignored and only
|
|
24
|
+
* the keys the client sent are compared. Null and absent count as equal.
|
|
25
|
+
*/ const survives = (expected, actual) => {
|
|
26
|
+
if (expected === void 0 || expected === null) return actual === void 0 || actual === null;
|
|
27
|
+
if (Array.isArray(expected)) return Array.isArray(actual) && expected.length === actual.length && expected.every((entry, index) => survives(entry, actual[index]));
|
|
28
|
+
if (isPlainObject(expected)) return isPlainObject(actual) && Object.entries(expected).every(([key, value]) => key === "id" || survives(value, actual[key]));
|
|
29
|
+
return isPlainObject(actual) || Array.isArray(actual) ? false : JSON.stringify(expected) === JSON.stringify(actual);
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Pointers whose intended value did not survive the write. Element pointers
|
|
33
|
+
* are skipped: an append pointer (`/-`) does not resolve against the saved
|
|
34
|
+
* document.
|
|
35
|
+
*/ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operation) => {
|
|
36
|
+
if (operation.op !== "add" && operation.op !== "replace" || isElementPointer(operation.path)) return [];
|
|
37
|
+
const pointer = Pointer.fromJSON(operation.path);
|
|
38
|
+
const expected = pointer.get(intended);
|
|
39
|
+
const actual = pointer.get(saved);
|
|
40
|
+
return survives(expected, actual) ? [] : [operation.path];
|
|
41
|
+
});
|
|
42
|
+
const patchDocument = {
|
|
43
|
+
name: "patchDocument",
|
|
44
|
+
description: DESCRIPTION,
|
|
45
|
+
annotations: {
|
|
46
|
+
readOnlyHint: false,
|
|
47
|
+
destructiveHint: false,
|
|
48
|
+
idempotentHint: false,
|
|
49
|
+
openWorldHint: false
|
|
50
|
+
},
|
|
51
|
+
isEnabled: (scope) => scope.writable.length > 0,
|
|
52
|
+
inputSchema: (scope) => ({
|
|
53
|
+
collection: collectionEnum(scope.writable).describe("Collection holding the document."),
|
|
54
|
+
id: idSchema,
|
|
55
|
+
...localeShape(scope, {
|
|
56
|
+
required: true,
|
|
57
|
+
description: "Locale the patch applies to. Localized fields write here only."
|
|
58
|
+
}),
|
|
59
|
+
patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
|
|
60
|
+
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
|
|
61
|
+
}),
|
|
62
|
+
handler: async (args, scope) => {
|
|
63
|
+
const collection = ensureAllowed(scope, args.collection, "write");
|
|
64
|
+
const { payload } = scope.req;
|
|
65
|
+
const locale = localeOf(scope, args.locale);
|
|
66
|
+
return await withTransaction(scope.req, async () => {
|
|
67
|
+
const doc = await readDraft(scope, {
|
|
68
|
+
collection: args.collection,
|
|
69
|
+
id: args.id,
|
|
70
|
+
locale
|
|
71
|
+
});
|
|
72
|
+
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"] });
|
|
73
|
+
const problems = findPatchProblems(payload.config, {
|
|
74
|
+
collection: args.collection,
|
|
75
|
+
doc,
|
|
76
|
+
patches: args.patches
|
|
77
|
+
});
|
|
78
|
+
if (problems.length > 0) return errorResult("No operation was applied.", { problems });
|
|
79
|
+
const applied = applyPatchToCopy(doc, args.patches);
|
|
80
|
+
if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
|
|
81
|
+
await payload.update({
|
|
82
|
+
collection: args.collection,
|
|
83
|
+
id: args.id,
|
|
84
|
+
data: buildWriteData(payload.config, collection, applied.next),
|
|
85
|
+
depth: 0,
|
|
86
|
+
draft: true,
|
|
87
|
+
overrideAccess: false,
|
|
88
|
+
req: scope.req,
|
|
89
|
+
...locale === void 0 ? {} : { locale }
|
|
90
|
+
});
|
|
91
|
+
const saved = await readDraft(scope, {
|
|
92
|
+
collection: args.collection,
|
|
93
|
+
id: args.id,
|
|
94
|
+
locale,
|
|
95
|
+
privileged: true
|
|
96
|
+
});
|
|
97
|
+
const notApplied = notAppliedPointers(args.patches, applied.next, saved);
|
|
98
|
+
const publishBlockers = await collectPublishBlockers(scope.req, {
|
|
99
|
+
collection,
|
|
100
|
+
doc: saved
|
|
101
|
+
});
|
|
102
|
+
return jsonResult({
|
|
103
|
+
id: saved["id"],
|
|
104
|
+
status: saved["_status"],
|
|
105
|
+
updatedAt: saved["updatedAt"],
|
|
106
|
+
...publishBlockers.length > 0 ? { publishBlockers } : {},
|
|
107
|
+
...notApplied.length > 0 ? { notApplied } : {}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
//#endregion
|
|
113
|
+
export { patchDocument };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Forbidden, NotFound } from "payload";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/tools/shared.ts
|
|
4
|
+
const collectionEnum = (slugs) => z.enum(slugs);
|
|
5
|
+
const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
6
|
+
/**
|
|
7
|
+
* The `locale` argument, present only when localization is configured.
|
|
8
|
+
*/ const localeShape = (scope, options) => {
|
|
9
|
+
if (!scope.locales) return {};
|
|
10
|
+
const locale = z.enum(scope.locales);
|
|
11
|
+
return { locale: (options.required ? locale : locale.optional()).describe(options.description) };
|
|
12
|
+
};
|
|
13
|
+
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)}.`) });
|
|
14
|
+
/**
|
|
15
|
+
* Throws unless the key may perform `operation` on `slug`. The input schema
|
|
16
|
+
* already limits the enum, so this only guards against a stale tool list.
|
|
17
|
+
*/ const ensureAllowed = (scope, slug, operation) => {
|
|
18
|
+
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
19
|
+
const collection = scope.req.payload.collections[slug];
|
|
20
|
+
if (!allowed.includes(slug) || !collection) throw new Forbidden(scope.req.t);
|
|
21
|
+
return collection.config;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The locale to operate on: the explicit argument, else the request's, else
|
|
25
|
+
* the default. `undefined` when localization is off.
|
|
26
|
+
*/ const localeOf = (scope, locale) => {
|
|
27
|
+
if (!scope.locales) return;
|
|
28
|
+
const requested = locale ?? scope.req.locale;
|
|
29
|
+
return (requested && scope.locales.includes(requested) ? requested : scope.defaultLocale) ?? void 0;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Reads the current draft in a fixed locale with no fallback, which is the
|
|
33
|
+
* shape that may be written back or validated without mixing locales.
|
|
34
|
+
*/ const readDraft = async (scope, args) => {
|
|
35
|
+
const doc = await scope.req.payload.findByID({
|
|
36
|
+
collection: args.collection,
|
|
37
|
+
id: args.id,
|
|
38
|
+
depth: 0,
|
|
39
|
+
draft: true,
|
|
40
|
+
...args.locale === void 0 ? {} : {
|
|
41
|
+
locale: args.locale,
|
|
42
|
+
fallbackLocale: false
|
|
43
|
+
},
|
|
44
|
+
overrideAccess: args.privileged === true,
|
|
45
|
+
showHiddenFields: args.privileged === true,
|
|
46
|
+
disableErrors: true,
|
|
47
|
+
req: scope.req
|
|
48
|
+
});
|
|
49
|
+
if (!doc) throw new NotFound(scope.req.t);
|
|
50
|
+
return doc;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Resolves a collection label for the request's language.
|
|
54
|
+
*/ const translateLabel = (scope, label, fallback) => {
|
|
55
|
+
const { i18n, t } = scope.req;
|
|
56
|
+
const resolved = typeof label === "function" ? label({
|
|
57
|
+
i18n,
|
|
58
|
+
t
|
|
59
|
+
}) : label;
|
|
60
|
+
if (typeof resolved === "string") return resolved;
|
|
61
|
+
if (resolved && typeof resolved === "object") return resolved[i18n.language] ?? Object.values(resolved)[0] ?? fallback;
|
|
62
|
+
return fallback;
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { collectionEnum, depthShape, ensureAllowed, idSchema, localeOf, localeShape, readDraft, translateLabel };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { jsonResult } from "../endpoint/result.mjs";
|
|
2
|
+
import { collectionEnum, ensureAllowed, idSchema, localeOf, localeShape, readDraft } from "./shared.mjs";
|
|
3
|
+
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
4
|
+
//#region src/tools/validate-document.ts
|
|
5
|
+
const validateDocument = {
|
|
6
|
+
name: "validateDocument",
|
|
7
|
+
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".`,
|
|
8
|
+
annotations: {
|
|
9
|
+
readOnlyHint: true,
|
|
10
|
+
openWorldHint: false
|
|
11
|
+
},
|
|
12
|
+
isEnabled: (scope) => scope.writable.length > 0,
|
|
13
|
+
inputSchema: (scope) => ({
|
|
14
|
+
collection: collectionEnum(scope.writable).describe("Collection holding the document."),
|
|
15
|
+
id: idSchema,
|
|
16
|
+
...localeShape(scope, {
|
|
17
|
+
required: true,
|
|
18
|
+
description: "Locale to validate."
|
|
19
|
+
})
|
|
20
|
+
}),
|
|
21
|
+
handler: async (args, scope) => {
|
|
22
|
+
const collection = ensureAllowed(scope, args.collection, "write");
|
|
23
|
+
const locale = localeOf(scope, args.locale);
|
|
24
|
+
await readDraft(scope, {
|
|
25
|
+
collection: args.collection,
|
|
26
|
+
id: args.id,
|
|
27
|
+
locale
|
|
28
|
+
});
|
|
29
|
+
const doc = await readDraft(scope, {
|
|
30
|
+
collection: args.collection,
|
|
31
|
+
id: args.id,
|
|
32
|
+
locale,
|
|
33
|
+
privileged: true
|
|
34
|
+
});
|
|
35
|
+
const publishBlockers = await collectPublishBlockers(scope.req, {
|
|
36
|
+
collection,
|
|
37
|
+
doc
|
|
38
|
+
});
|
|
39
|
+
return jsonResult({
|
|
40
|
+
id: doc["id"],
|
|
41
|
+
status: doc["_status"],
|
|
42
|
+
updatedAt: doc["updatedAt"],
|
|
43
|
+
publishBlockers
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { validateDocument };
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { CollectionConfig, CollectionSlug, 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
|
+
/** Expose `describeSchema`, `findDocuments` and `getDocument`. Default `true`. */
|
|
20
|
+
read?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Expose `patchDocument`, `createDocument` and `validateDocument`. Default
|
|
23
|
+
* `false`. Requires `versions.drafts` unless `allowLiveWrites` is set.
|
|
24
|
+
*/
|
|
25
|
+
write?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Permit writes to a collection without drafts. Such writes land on the live
|
|
28
|
+
* document because there is no draft to land on. Default `false`.
|
|
29
|
+
*/
|
|
30
|
+
allowLiveWrites?: boolean;
|
|
31
|
+
}
|
|
32
|
+
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
33
|
+
/**
|
|
34
|
+
* A custom tool. It is gated by its own checkbox on every API key and runs
|
|
35
|
+
* with `req.user` resolved from the key and `req.context.mcpx` set.
|
|
36
|
+
*/
|
|
37
|
+
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape> {
|
|
38
|
+
/** camelCase, unique, not one of the builtin tool names. */
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
inputSchema?: Shape;
|
|
42
|
+
annotations?: ToolAnnotations;
|
|
43
|
+
handler(ctx: {
|
|
44
|
+
args: z.infer<z.ZodObject<Shape>>;
|
|
45
|
+
req: PayloadRequest;
|
|
46
|
+
extra: McpxToolExtra;
|
|
47
|
+
}): CallToolResult | Promise<CallToolResult>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Identity helper that infers the argument type of a custom tool's handler
|
|
51
|
+
* from its input schema.
|
|
52
|
+
*/
|
|
53
|
+
declare const defineMcpxTool: <Shape extends z.ZodRawShape>(tool: McpxTool<Shape>) => McpxTool<Shape>;
|
|
54
|
+
/**
|
|
55
|
+
* Outcome of resolving an API key. `user` must carry `collection`.
|
|
56
|
+
*/
|
|
57
|
+
interface McpxAuthResult {
|
|
58
|
+
user: TypedUser;
|
|
59
|
+
apiKeyId: number | string;
|
|
60
|
+
/** The `capabilities` group as stored on the key document. */
|
|
61
|
+
capabilities: unknown;
|
|
62
|
+
}
|
|
63
|
+
type McpxPluginOptions = {
|
|
64
|
+
/** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
|
|
65
|
+
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
66
|
+
/** Collection the keys act as. Default `config.admin.user`, then `users`. */
|
|
67
|
+
userCollection?: CollectionSlug;
|
|
68
|
+
apiKeys?: {
|
|
69
|
+
/** Slug of the generated API key collection. Default `mcpx-api-keys`. */
|
|
70
|
+
slug?: string;
|
|
71
|
+
/** Final override applied to the generated collection. */
|
|
72
|
+
overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
|
|
73
|
+
};
|
|
74
|
+
endpoint?: {
|
|
75
|
+
/** Endpoint path below the API route. Default `/mcpx`. */
|
|
76
|
+
path?: string;
|
|
77
|
+
};
|
|
78
|
+
limits?: {
|
|
79
|
+
/** Upper bound for `findDocuments.limit`. Default 25. */
|
|
80
|
+
maxLimit?: number;
|
|
81
|
+
/** Upper bound for `depth` on reads. Default 1. */
|
|
82
|
+
maxDepth?: number;
|
|
83
|
+
};
|
|
84
|
+
tools?: McpxTool[];
|
|
85
|
+
auth?: {
|
|
86
|
+
/** Replace or wrap the default key resolution. Return `null` for 401. */
|
|
87
|
+
resolve?: (args: {
|
|
88
|
+
req: PayloadRequest;
|
|
89
|
+
resolveDefault: () => Promise<McpxAuthResult | null>;
|
|
90
|
+
}) => Promise<McpxAuthResult | null>;
|
|
91
|
+
};
|
|
92
|
+
serverInfo?: {
|
|
93
|
+
name?: string;
|
|
94
|
+
version?: string;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
interface McpxCollectionCapabilities {
|
|
98
|
+
read: boolean;
|
|
99
|
+
write: boolean;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Capabilities in force for one request: plugin config AND key checkboxes.
|
|
103
|
+
*/
|
|
104
|
+
interface McpxResolvedCapabilities {
|
|
105
|
+
collections: Record<string, McpxCollectionCapabilities>;
|
|
106
|
+
tools: Record<string, boolean>;
|
|
107
|
+
}
|
|
108
|
+
interface McpxRequestContext {
|
|
109
|
+
apiKeyId: number | string;
|
|
110
|
+
capabilities: McpxResolvedCapabilities;
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, 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 };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { APIError } from "payload";
|
|
2
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
3
|
+
//#region src/write/draft-guard.ts
|
|
4
|
+
/**
|
|
5
|
+
* Operation arguments that widen or redirect a write. Cleared on every MCP
|
|
6
|
+
* create and update so a tool cannot smuggle them in.
|
|
7
|
+
*/ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
8
|
+
"where",
|
|
9
|
+
"publishAllLocales",
|
|
10
|
+
"publishSpecificLocale",
|
|
11
|
+
"unpublishAllLocales",
|
|
12
|
+
"duplicateFromID",
|
|
13
|
+
"selectedLocales",
|
|
14
|
+
"overwriteExistingFiles"
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* Whether a request originated from the MCP endpoint. The endpoint stamps
|
|
18
|
+
* `req.context.mcpx`, which travels into every local API call made with the
|
|
19
|
+
* same `req`, including those made by custom tools.
|
|
20
|
+
*/ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
|
|
21
|
+
/**
|
|
22
|
+
* Forces every MCP write into a draft save.
|
|
23
|
+
*
|
|
24
|
+
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
25
|
+
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
26
|
+
* This runs as `beforeOperation`, before Payload reads any of these arguments,
|
|
27
|
+
* so it holds for every create and update on an MCP request, not only the
|
|
28
|
+
* builtin tools. Deletes are not guarded in v1; custom tools that delete are
|
|
29
|
+
* the integrator's responsibility.
|
|
30
|
+
*/ const forceDraftWrite = (hookArgs) => {
|
|
31
|
+
const { args, operation, req } = hookArgs;
|
|
32
|
+
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
33
|
+
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
34
|
+
if (next["data"] && typeof next["data"] === "object") {
|
|
35
|
+
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
36
|
+
next["data"] = data;
|
|
37
|
+
}
|
|
38
|
+
next["draft"] = true;
|
|
39
|
+
next["autosave"] = false;
|
|
40
|
+
next["overrideLock"] = false;
|
|
41
|
+
next["trash"] = false;
|
|
42
|
+
return next;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Refuses an MCP write that would still not land as a draft. An alarm rather
|
|
46
|
+
* than the guarantee: `forceDraftWrite` should make it unreachable. It throws
|
|
47
|
+
* instead of correcting `_status` because Payload has already chosen the write
|
|
48
|
+
* branch by the time a `beforeChange` hook runs.
|
|
49
|
+
*/ const refusePublish = ({ collection, data, req }) => {
|
|
50
|
+
if (!isMcpxRequest(req)) return data;
|
|
51
|
+
const status = data._status;
|
|
52
|
+
if (status === "draft") return data;
|
|
53
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${collection.slug} that would not have been a draft (_status: ${String(status)}).`);
|
|
54
|
+
throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
58
|
+
* (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
|
|
59
|
+
* exist. Applied to the built collection list so nothing can join later
|
|
60
|
+
* without being covered.
|
|
61
|
+
*/ const installDraftGuards = (collections) => collections.map((collection) => ({
|
|
62
|
+
...collection,
|
|
63
|
+
hooks: {
|
|
64
|
+
...collection.hooks,
|
|
65
|
+
beforeOperation: [...collection.hooks?.beforeOperation ?? [], forceDraftWrite],
|
|
66
|
+
...hasDraftsEnabled(collection) ? { beforeChange: [...collection.hooks?.beforeChange ?? [], refusePublish] } : {}
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
//#endregion
|
|
70
|
+
export { forceDraftWrite, installDraftGuards, isMcpxRequest, refusePublish };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { RESERVED_FIELD_NAMES, blockOf, describeFields, findBlocksField, splitPath } from "../schema/walk.mjs";
|
|
2
|
+
import { validateWriteValue } from "../schema/shape.mjs";
|
|
3
|
+
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { Pointer, applyPatch } from "rfc6902";
|
|
6
|
+
//#region src/write/patch.ts
|
|
7
|
+
/**
|
|
8
|
+
* One RFC 6902 operation as accepted by `patchDocument`.
|
|
9
|
+
*/ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
|
|
10
|
+
const PATCH_OPERATION_SCHEMA = z.object({
|
|
11
|
+
from: z.string().regex(JSON_POINTER_PATTERN).optional(),
|
|
12
|
+
op: z.enum([
|
|
13
|
+
"add",
|
|
14
|
+
"copy",
|
|
15
|
+
"move",
|
|
16
|
+
"remove",
|
|
17
|
+
"replace",
|
|
18
|
+
"test"
|
|
19
|
+
]),
|
|
20
|
+
path: z.string().regex(JSON_POINTER_PATTERN),
|
|
21
|
+
value: z.unknown().optional()
|
|
22
|
+
}).describe("An RFC 6902 operation.");
|
|
23
|
+
/**
|
|
24
|
+
* Whether a pointer touches a field Payload maintains.
|
|
25
|
+
*/ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
|
|
26
|
+
/**
|
|
27
|
+
* The pointer an operation removes a value from, if it removes one at all.
|
|
28
|
+
*/ const droppedPointer = (operation) => {
|
|
29
|
+
if (operation.op === "remove") return operation.path;
|
|
30
|
+
return operation.op === "move" ? operation.from : void 0;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Whether a pointer addresses a list element rather than a field.
|
|
34
|
+
*/ const isElementPointer = (pointer) => {
|
|
35
|
+
const last = pointer.split("/").pop() ?? "";
|
|
36
|
+
return last === "-" || /^\d+$/.test(last);
|
|
37
|
+
};
|
|
38
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
/**
|
|
40
|
+
* A Lexical editor state. Its nodes manage their own ids, so it is never
|
|
41
|
+
* descended into.
|
|
42
|
+
*/ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
|
|
43
|
+
/**
|
|
44
|
+
* Visits every row in a value: a plain object that carries `blockType` or
|
|
45
|
+
* sits directly inside an array. Rich text states manage their own nodes and
|
|
46
|
+
* are never descended into.
|
|
47
|
+
*/ const walkRows = (value, visit, isRow = false) => {
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
for (const entry of value) walkRows(entry, visit, true);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!isPlainObject(value) || isRichTextState(value)) return;
|
|
53
|
+
if (isRow || typeof value["blockType"] === "string") visit(value);
|
|
54
|
+
for (const entry of Object.values(value)) walkRows(entry, visit);
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Keeps a row id only when the stored document already has it and no earlier
|
|
58
|
+
* row in the write claimed it; every other id is dropped so Payload assigns a
|
|
59
|
+
* fresh one.
|
|
60
|
+
*
|
|
61
|
+
* A kept id makes Payload update the row in place, which preserves the other
|
|
62
|
+
* locales of any localized field inside it. A duplicated id (a copied row) or
|
|
63
|
+
* an id from elsewhere would violate a SQL primary key, so those never pass.
|
|
64
|
+
*/ const reconcileRowIds = (next, stored) => {
|
|
65
|
+
const known = /* @__PURE__ */ new Set();
|
|
66
|
+
walkRows(stored, (row) => {
|
|
67
|
+
if (row["id"] !== void 0) known.add(row["id"]);
|
|
68
|
+
});
|
|
69
|
+
const seen = /* @__PURE__ */ new Set();
|
|
70
|
+
walkRows(next, (row) => {
|
|
71
|
+
const id = row["id"];
|
|
72
|
+
if (id === void 0) return;
|
|
73
|
+
if (known.has(id) && !seen.has(id)) {
|
|
74
|
+
seen.add(id);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
delete row["id"];
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Drops every row id from a copy of `value`. Used on create, where no stored
|
|
82
|
+
* row exists and any incoming id is client-invented.
|
|
83
|
+
*/ const stripRowIds = (value) => {
|
|
84
|
+
const next = structuredClone(value);
|
|
85
|
+
walkRows(next, (row) => {
|
|
86
|
+
delete row["id"];
|
|
87
|
+
});
|
|
88
|
+
return next;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Applies a patch to a deep copy of the document, so a failing operation
|
|
92
|
+
* leaves the original untouched and nothing partial is ever written.
|
|
93
|
+
*/ const applyPatchToCopy = (doc, patches) => {
|
|
94
|
+
const next = structuredClone(doc);
|
|
95
|
+
const prepared = patches.map((operation) => {
|
|
96
|
+
const cloned = "value" in operation ? {
|
|
97
|
+
...operation,
|
|
98
|
+
value: structuredClone(operation.value)
|
|
99
|
+
} : operation;
|
|
100
|
+
if (cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(next) === void 0) return {
|
|
101
|
+
...cloned,
|
|
102
|
+
op: "add"
|
|
103
|
+
};
|
|
104
|
+
return cloned;
|
|
105
|
+
});
|
|
106
|
+
const problems = applyPatch(next, prepared).flatMap((error, index) => error ? [`patches[${String(index)}]: ${error.message}`] : []);
|
|
107
|
+
if (problems.length > 0) return { problems };
|
|
108
|
+
reconcileRowIds(next, doc);
|
|
109
|
+
return { next };
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Checks every operation against the schema before any is applied.
|
|
113
|
+
*
|
|
114
|
+
* A partially applied batch is worse than a refused one, so this returns all
|
|
115
|
+
* problems and the caller applies nothing unless the list is empty.
|
|
116
|
+
*/ const findPatchProblems = (config, target) => target.patches.flatMap((operation, index) => {
|
|
117
|
+
const at = `patches[${String(index)}]`;
|
|
118
|
+
const pointers = [operation.path, ..."from" in operation && operation.from ? [operation.from] : []];
|
|
119
|
+
if (pointers.includes("")) return [`${at}: an empty pointer addresses the whole document. Address a field instead.`];
|
|
120
|
+
const reserved = pointers.find(isReservedPointer);
|
|
121
|
+
if (reserved !== void 0) return [`${at}: "${reserved}" addresses a field Payload maintains. Drafts are the only thing this tool writes, and id, _status, createdAt and updatedAt are not writable.`];
|
|
122
|
+
const dropped = droppedPointer(operation);
|
|
123
|
+
if (dropped !== void 0 && !isElementPointer(dropped)) return [`${at}: "${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
|
|
124
|
+
const value = "value" in operation ? operation.value : void 0;
|
|
125
|
+
try {
|
|
126
|
+
const moved = "from" in operation && operation.from ? Pointer.fromJSON(operation.from).get(target.doc) : void 0;
|
|
127
|
+
for (const pointer of pointers) {
|
|
128
|
+
const resolution = resolveDataPointer(config, {
|
|
129
|
+
addedValue: value ?? moved,
|
|
130
|
+
collection: target.collection,
|
|
131
|
+
doc: target.doc,
|
|
132
|
+
pointer
|
|
133
|
+
});
|
|
134
|
+
if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
|
|
135
|
+
pointer,
|
|
136
|
+
resolution
|
|
137
|
+
}, value).map((problem) => `${at}: ${problem}`);
|
|
138
|
+
}
|
|
139
|
+
return [];
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return [`${at}: ${error instanceof Error ? error.message : "invalid"}`];
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
/**
|
|
145
|
+
* Keys Payload manages on a row that travel back into the write unchanged.
|
|
146
|
+
*/ const ROW_KEYS = /* @__PURE__ */ new Set([
|
|
147
|
+
"blockName",
|
|
148
|
+
"blockType",
|
|
149
|
+
"id"
|
|
150
|
+
]);
|
|
151
|
+
/**
|
|
152
|
+
* Picks the keys the schema walker describes out of `value`, descending into
|
|
153
|
+
* groups, named tabs, arrays and blocks. Everything Payload maintains or
|
|
154
|
+
* derives (`_status`, timestamps, join and virtual fields, upload base fields)
|
|
155
|
+
* is left out, so the write-back carries only what a client could have set.
|
|
156
|
+
*/ const pickDescribed = (config, value, at) => {
|
|
157
|
+
const { fields, prefix, isRow } = at;
|
|
158
|
+
const relative = describeFields(fields).flatMap((descriptor) => {
|
|
159
|
+
const parts = splitPath(descriptor.path);
|
|
160
|
+
return prefix.every((part, offset) => part === parts[offset]) ? [{
|
|
161
|
+
descriptor,
|
|
162
|
+
parts: parts.slice(prefix.length)
|
|
163
|
+
}] : [];
|
|
164
|
+
});
|
|
165
|
+
const result = {};
|
|
166
|
+
if (isRow) {
|
|
167
|
+
for (const key of ROW_KEYS) if (key in value) result[key] = value[key];
|
|
168
|
+
}
|
|
169
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
170
|
+
const candidates = relative.filter(({ parts }) => parts[0] === key);
|
|
171
|
+
if (candidates.length === 0) continue;
|
|
172
|
+
const exact = candidates.find(({ parts }) => parts.length === 1);
|
|
173
|
+
if (exact?.descriptor.type === "blocks" && Array.isArray(entry)) {
|
|
174
|
+
const field = findBlocksField(fields, splitPath(exact.descriptor.path));
|
|
175
|
+
result[key] = entry.map((row) => {
|
|
176
|
+
const block = field && isPlainObject(row) && typeof row["blockType"] === "string" ? blockOf(config, field, row["blockType"]) : void 0;
|
|
177
|
+
return block && isPlainObject(row) ? pickDescribed(config, row, {
|
|
178
|
+
fields: block.flattenedFields,
|
|
179
|
+
prefix: [],
|
|
180
|
+
isRow: true
|
|
181
|
+
}) : row;
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (exact) {
|
|
186
|
+
result[key] = entry;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (candidates.some(({ parts }) => parts[1] === "[]")) {
|
|
190
|
+
result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
|
|
191
|
+
fields,
|
|
192
|
+
prefix: [
|
|
193
|
+
...prefix,
|
|
194
|
+
key,
|
|
195
|
+
"[]"
|
|
196
|
+
],
|
|
197
|
+
isRow: true
|
|
198
|
+
}) : row) : entry;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
result[key] = isPlainObject(entry) ? pickDescribed(config, entry, {
|
|
202
|
+
fields,
|
|
203
|
+
prefix: [...prefix, key],
|
|
204
|
+
isRow: false
|
|
205
|
+
}) : entry;
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* The data handed to `payload.update` after a patch: the patched document
|
|
211
|
+
* reduced to the fields the client may write, plus row identity keys.
|
|
212
|
+
*/ const buildWriteData = (config, collection, doc) => {
|
|
213
|
+
return pickDescribed(config, doc, {
|
|
214
|
+
fields: collection.flattenedFields,
|
|
215
|
+
prefix: [],
|
|
216
|
+
isRow: false
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
//#endregion
|
|
220
|
+
export { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, droppedPointer, findPatchProblems, isElementPointer, isReservedPointer, stripRowIds };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PayloadRequest } from "payload";
|
|
2
|
+
//#region src/write/publish-blockers.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* One reason a human could not publish the draft as it stands.
|
|
5
|
+
*/
|
|
6
|
+
interface PublishBlocker {
|
|
7
|
+
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
8
|
+
field?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
path: string;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export type { PublishBlocker };
|