@abinnovision/payloadcms-mcpx 2.0.0-beta.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -36
  3. package/dist/admin/capability-matrix.d.mts +17 -7
  4. package/dist/admin/capability-matrix.mjs +374 -272
  5. package/dist/admin/capability-toggles.mjs +79 -42
  6. package/dist/admin/confirmations-panel.mjs +232 -0
  7. package/dist/admin/confirmations.d.mts +15 -0
  8. package/dist/admin/confirmations.mjs +71 -0
  9. package/dist/admin/endpoint-url.mjs +7 -0
  10. package/dist/admin/index.d.mts +2 -1
  11. package/dist/admin/index.mjs +2 -1
  12. package/dist/admin/setup-guide.d.mts +6 -4
  13. package/dist/admin/setup-guide.mjs +51 -33
  14. package/dist/api-keys/capability-matrix.d.mts +12 -10
  15. package/dist/api-keys/capability-matrix.mjs +17 -37
  16. package/dist/api-keys/collection.mjs +18 -19
  17. package/dist/api-keys/confirmation-view.mjs +7 -0
  18. package/dist/api-keys/fields.mjs +67 -53
  19. package/dist/auth/resolve.mjs +79 -30
  20. package/dist/capabilities.d.mts +2 -0
  21. package/dist/capabilities.mjs +69 -15
  22. package/dist/define-tool.d.mts +10 -6
  23. package/dist/define-tool.mjs +8 -1
  24. package/dist/endpoint/confirmations.mjs +124 -0
  25. package/dist/endpoint/handler.mjs +25 -27
  26. package/dist/endpoint/http.mjs +27 -0
  27. package/dist/endpoint/server.mjs +52 -27
  28. package/dist/endpoint/upload.mjs +104 -0
  29. package/dist/grants/confirmation.mjs +75 -0
  30. package/dist/grants/grant.mjs +137 -0
  31. package/dist/guards.mjs +11 -2
  32. package/dist/i18n.mjs +3 -1
  33. package/dist/index.d.mts +2 -2
  34. package/dist/index.mjs +1 -1
  35. package/dist/options.mjs +73 -62
  36. package/dist/plugin.d.mts +3 -2
  37. package/dist/plugin.mjs +19 -4
  38. package/dist/request.mjs +5 -1
  39. package/dist/result.mjs +11 -1
  40. package/dist/schema/admin-hidden.mjs +4 -4
  41. package/dist/schema/describe.mjs +12 -18
  42. package/dist/schema/index.mjs +5 -6
  43. package/dist/schema/lexical-pointer.mjs +8 -8
  44. package/dist/schema/lexical.mjs +21 -15
  45. package/dist/schema/outline.mjs +4 -5
  46. package/dist/schema/path.mjs +13 -4
  47. package/dist/schema/pointer.mjs +11 -17
  48. package/dist/schema/shape.mjs +39 -51
  49. package/dist/schema/walk.mjs +68 -21
  50. package/dist/tools/builtin.mjs +7 -1
  51. package/dist/tools/create-document.mjs +38 -26
  52. package/dist/tools/delete-document.mjs +154 -0
  53. package/dist/tools/describe-schema.mjs +7 -5
  54. package/dist/tools/document.mjs +147 -21
  55. package/dist/tools/duplicate-document.mjs +50 -0
  56. package/dist/tools/find-documents.mjs +15 -20
  57. package/dist/tools/find-versions.mjs +11 -15
  58. package/dist/tools/get-document.mjs +31 -15
  59. package/dist/tools/links.mjs +53 -0
  60. package/dist/tools/list-capabilities.mjs +17 -7
  61. package/dist/tools/patch-document.mjs +60 -54
  62. package/dist/tools/publish-document.mjs +23 -30
  63. package/dist/tools/query-paths.mjs +4 -2
  64. package/dist/tools/run-confirmed.mjs +114 -0
  65. package/dist/tools/shared.mjs +66 -50
  66. package/dist/tools/validate-document.mjs +10 -11
  67. package/dist/tools/versions.mjs +24 -10
  68. package/dist/types.d.mts +228 -60
  69. package/dist/upload/file.mjs +177 -0
  70. package/dist/version.mjs +4 -2
  71. package/dist/write/draft-guard.mjs +46 -37
  72. package/dist/write/patch.mjs +4 -4
  73. package/dist/write/publish-intent.mjs +25 -11
  74. package/dist/write/row-ids.mjs +6 -2
  75. package/dist/write/write-data.mjs +18 -31
  76. package/package.json +15 -14
  77. package/dist/builtin-tool-names.mjs +0 -16
@@ -0,0 +1,137 @@
1
+ import { isPlainObject } from "../guards.mjs";
2
+ import { generateApiKey, hashApiKey } from "../api-keys/key.mjs";
3
+ import { APIError, ValidationError } from "payload";
4
+ //#region src/grants/grant.ts
5
+ const GRANT_TTL_MS = 3e5;
6
+ const GRANTS_PER_KEY = 10;
7
+ const ROW_ID = /^[\w-]{43}$/;
8
+ const NO_REQ = {};
9
+ /**
10
+ * The key prefixes of a kind of row: waiting rows and the markers of used
11
+ * ones.
12
+ */ const rowPrefixes = (kind, apiKeyId) => ({
13
+ prefix: `mcpx-${kind}:${String(apiKeyId)}:`,
14
+ usedPrefix: `mcpx-${kind}-used:${String(apiKeyId)}:`
15
+ });
16
+ /**
17
+ * What a row is addressed by: the HMAC of its id, which the row key ends in.
18
+ */ const rowHandle = (payload, id) => hashApiKey(payload.secret, id);
19
+ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ? data["exp"] : 0;
20
+ /**
21
+ * The KV collection slug, when Payload's database KV adapter backs it with a
22
+ * unique `key`, which the single-use claim relies on. `undefined` otherwise.
23
+ */ const grantKvSlug = (config) => {
24
+ const kv = config.kv;
25
+ const key = kv?.kvCollection?.fields.find((field) => "name" in field && field.name === "key");
26
+ return key && "unique" in key && key.unique ? kv?.kvCollection?.slug : void 0;
27
+ };
28
+ /**
29
+ * KV rows whose key contains one of `fragments`.
30
+ */ const findRows = async (payload, slug, fragments) => {
31
+ const { docs } = await payload.db.find({
32
+ collection: slug,
33
+ where: { or: fragments.map((fragment) => ({ key: { like: fragment } })) },
34
+ limit: 0,
35
+ pagination: false,
36
+ req: NO_REQ
37
+ });
38
+ return docs;
39
+ };
40
+ /**
41
+ * KV rows whose key starts with one of `prefixes`. `like` matches a
42
+ * substring, so the prefix is checked again here.
43
+ */ const rowsWith = async (payload, slug, prefixes) => (await findRows(payload, slug, prefixes)).filter((row) => prefixes.some((prefix) => row.key.startsWith(prefix)));
44
+ /**
45
+ * Stores `data` under `prefix` and the HMAC of a fresh id, and returns the id,
46
+ * the only copy of which goes to the client. Expired rows under `prefix` and
47
+ * `usedPrefix` are purged first, and at most `cap` unexpired rows may wait
48
+ * under `prefix`; past that, `capMessage` is thrown with 429.
49
+ */ const issueRow = async (payload, slug, args) => {
50
+ const now = Date.now();
51
+ const rows = await rowsWith(payload, slug, [args.prefix, args.usedPrefix]);
52
+ const expired = rows.filter((row) => expOf(row.data) <= now);
53
+ if (expired.length > 0) await payload.db.deleteMany({
54
+ collection: slug,
55
+ where: { key: { in: expired.map((row) => row.key) } },
56
+ req: NO_REQ
57
+ });
58
+ if (rows.filter((row) => row.key.startsWith(args.prefix) && expOf(row.data) > now).length >= args.cap) throw new APIError(args.capMessage, 429);
59
+ const id = generateApiKey();
60
+ const exp = now + args.ttlMs;
61
+ await payload.db.create({
62
+ collection: slug,
63
+ data: {
64
+ key: `${args.prefix}${rowHandle(payload, id)}`,
65
+ data: {
66
+ ...args.data,
67
+ exp
68
+ }
69
+ },
70
+ req: NO_REQ
71
+ });
72
+ return {
73
+ id,
74
+ exp
75
+ };
76
+ };
77
+ /**
78
+ * Marks the row under `handle` used and removes it. The marker is a
79
+ * unique-key insert, so of two concurrent claims exactly one returns `true`.
80
+ */ const claimRow = async (payload, slug, prefixes, handle, exp) => {
81
+ try {
82
+ await payload.db.create({
83
+ collection: slug,
84
+ data: {
85
+ key: `${prefixes.usedPrefix}${handle}`,
86
+ data: { exp }
87
+ },
88
+ req: NO_REQ
89
+ });
90
+ } catch (error) {
91
+ if (error instanceof ValidationError) return false;
92
+ throw error;
93
+ }
94
+ await payload.db.deleteOne({
95
+ collection: slug,
96
+ where: { key: { equals: `${prefixes.prefix}${handle}` } },
97
+ req: NO_REQ
98
+ });
99
+ return true;
100
+ };
101
+ /**
102
+ * The KV slug and key id of the request, which a call that stores a grant or
103
+ * a confirmation needs. Throws where the endpoint would not offer one.
104
+ */ const grantContext = (req) => {
105
+ const slug = grantKvSlug(req.payload.config);
106
+ const apiKeyId = req.context.mcpx?.apiKeyId;
107
+ if (slug === void 0 || apiKeyId === void 0) throw new Error("A grant or confirmation needs a database KV and a key.");
108
+ return {
109
+ slug,
110
+ apiKeyId
111
+ };
112
+ };
113
+ /**
114
+ * Stores a grant and returns its id. A key may hold at most
115
+ * {@link GRANTS_PER_KEY} grants.
116
+ */ const issueGrant = async (payload, slug, grant) => await issueRow(payload, slug, {
117
+ ...rowPrefixes("grant", grant.apiKeyId),
118
+ cap: GRANTS_PER_KEY,
119
+ capMessage: `This key already has ${String(GRANTS_PER_KEY)} uploads or downloads waiting. Use or abandon them first; each expires 5 minutes after it was issued.`,
120
+ ttlMs: GRANT_TTL_MS,
121
+ data: grant
122
+ });
123
+ /**
124
+ * Loads the grant for `grantId` and marks it used, or returns `undefined` for
125
+ * a malformed, unknown, expired or used one. A malformed id is refused before
126
+ * any database read.
127
+ */ const claimGrant = async (payload, slug, grantId) => {
128
+ if (!ROW_ID.test(grantId)) return;
129
+ const handle = rowHandle(payload, grantId);
130
+ const row = (await findRows(payload, slug, [handle])).find((candidate) => candidate.key.startsWith("mcpx-grant:") && candidate.key.endsWith(`:${handle}`));
131
+ const grant = row?.data;
132
+ if (!row || !isPlainObject(grant) || expOf(grant) <= Date.now()) return;
133
+ const prefixes = rowPrefixes("grant", grant.apiKeyId);
134
+ return row.key === `${prefixes.prefix}${handle}` && await claimRow(payload, slug, prefixes, handle, grant.exp) ? grant : void 0;
135
+ };
136
+ //#endregion
137
+ export { NO_REQ, ROW_ID, claimGrant, claimRow, expOf, grantContext, grantKvSlug, issueGrant, issueRow, rowHandle, rowPrefixes, rowsWith };
package/dist/guards.mjs CHANGED
@@ -1,5 +1,14 @@
1
1
  //#region src/guards.ts
2
2
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
- /** An own property only, so a client-chosen key never reads the prototype. */ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
3
+ /**
4
+ * A property of a plain object, `undefined` for any other value.
5
+ */ const propOf = (value, key) => isPlainObject(value) ? value[key] : void 0;
6
+ /**
7
+ * An own property only, so a client-chosen key never reads the prototype.
8
+ */ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
9
+ /**
10
+ * The entries whose value is not `undefined`, for spreading optional arguments
11
+ * into a call.
12
+ */ const definedProps = (props) => Object.fromEntries(Object.entries(props).filter(([, value]) => value !== void 0));
4
13
  //#endregion
5
- export { isPlainObject, ownValue };
14
+ export { definedProps, isPlainObject, ownValue, propOf };
package/dist/i18n.mjs CHANGED
@@ -21,7 +21,9 @@ const pick = (record, language) => {
21
21
  return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
22
22
  };
23
23
  const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
24
- /** For callers with no request. Both languages miss, so the first entry wins. */ const translateAny = translatorFor({
24
+ /**
25
+ * For callers with no request. Both languages miss, so the first entry wins.
26
+ */ const translateAny = translatorFor({
25
27
  fallbackLanguage: "",
26
28
  language: ""
27
29
  });
package/dist/index.d.mts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { DocumentId } from "./entity.mjs";
2
- import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxScopeSlugs, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode } from "./types.mjs";
2
+ import { McpxAnyTool, McpxAuthResult, McpxCollectionOptions, McpxEntityCapabilities, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxScopeSlugs, McpxTool, McpxToolExtra, McpxToolScope } from "./types.mjs";
3
3
  import { defineMcpxTool } from "./define-tool.mjs";
4
4
  import { mcpxPlugin } from "./plugin.mjs";
5
5
  import { isMcpxRequest } from "./request.mjs";
6
6
  import { errorResult, jsonResult } from "./result.mjs";
7
7
  import { mcpxReadRequest } from "./tools/read-request.mjs";
8
- export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type DocumentId as McpxDocumentId, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxScopeSlugs, type McpxTool, type McpxToolExtra, type McpxToolScope, type McpxWriteMode, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
8
+ export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionOptions, type DocumentId as McpxDocumentId, type McpxEntityCapabilities, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxScopeSlugs, type McpxTool, type McpxToolExtra, type McpxToolScope, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineMcpxTool } from "./define-tool.mjs";
2
+ import { isMcpxRequest } from "./request.mjs";
2
3
  import { errorResult, jsonResult } from "./result.mjs";
3
4
  import { mcpxReadRequest } from "./tools/read-request.mjs";
4
- import { isMcpxRequest } from "./request.mjs";
5
5
  import { mcpxPlugin } from "./plugin.mjs";
6
6
  export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
package/dist/options.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  import { isPlainObject } from "./guards.mjs";
2
- import { BUILTIN_TOOL_NAMES } from "./builtin-tool-names.mjs";
3
2
  import "./version.mjs";
4
3
  import { InvalidConfiguration } from "payload";
5
4
  import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
@@ -12,13 +11,21 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
12
11
  const fail = (message) => {
13
12
  throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
14
13
  };
15
- /** The same transform the stock MCP plugin uses to derive field names. */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
16
- const assertExposable = (collection, apiKeysSlug) => {
17
- const { slug } = collection;
18
- if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
19
- if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
14
+ /**
15
+ * The same transform the stock MCP plugin uses to derive field names.
16
+ */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
17
+ const assertExposable = (kind, config, apiKeysSlug) => {
18
+ const { slug } = config;
19
+ if (slug.startsWith("payload-") || kind === "Collection" && slug === apiKeysSlug) fail(`${kind} "${slug}" cannot be exposed.`);
20
+ if ("auth" in config && config.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
20
21
  };
21
22
  const OPTION_NAMES = [
23
+ "read",
24
+ "write",
25
+ "publish",
26
+ "delete"
27
+ ];
28
+ const GLOBAL_OPTION_NAMES = [
22
29
  "read",
23
30
  "write",
24
31
  "publish"
@@ -32,87 +39,82 @@ const readFlag = (kind, slug, name, value) => {
32
39
  const moved = name === "write" && typeof value === "string" ? REMOVED_WRITE_MODES[value] : void 0;
33
40
  return fail(`${kind} "${slug}" has ${name}: ${JSON.stringify(value)}. ${moved ?? "Use true or false."}`);
34
41
  };
35
- const assertWritable = (kind, config, write) => {
36
- if (write === "live" && hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which live writes do not support yet. Set publish: false or write: false.`);
42
+ const assertWritable = (kind, config, publish) => {
43
+ if (publish && hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which live writes do not support yet. Set publish: false or write: false.`);
44
+ };
45
+ const normalizeDelete = (kind, config, value) => {
46
+ if (value !== "unattended") return {
47
+ delete: readFlag(kind, config.slug, "delete", value) ?? false,
48
+ deleteUnattended: false
49
+ };
50
+ if (!("trash" in config) || !config.trash) fail(`${kind} "${config.slug}" has delete: "unattended" but no trash, so a delete without approval would be permanent. Enable trash or set delete: true.`);
51
+ return {
52
+ delete: true,
53
+ deleteUnattended: true
54
+ };
37
55
  };
38
56
  const normalizeCapabilities = (kind, config, raw) => {
39
57
  const { slug } = config;
58
+ const names = kind === "Global" ? GLOBAL_OPTION_NAMES : OPTION_NAMES;
40
59
  if (raw === false) fail(`${kind} "${slug}" is set to false. Remove the entry to hide the ${kind.toLowerCase()}.`);
41
- if (raw !== true && !isPlainObject(raw)) fail(`${kind} "${slug}" has ${JSON.stringify(raw)}. Use true or an object of ${OPTION_NAMES.join(", ")}.`);
60
+ if (raw !== true && !isPlainObject(raw)) fail(`${kind} "${slug}" has ${JSON.stringify(raw)}. Use true or an object of ${names.join(", ")}.`);
42
61
  const settings = isPlainObject(raw) ? raw : {};
43
- const unknownKey = Object.keys(settings).find((key) => !OPTION_NAMES.includes(key));
44
- if (unknownKey !== void 0) fail(`${kind} "${slug}" has the unknown option "${unknownKey}". The options are ${OPTION_NAMES.join(", ")}.`);
62
+ const unknownKey = Object.keys(settings).find((key) => !names.includes(key));
63
+ if (unknownKey !== void 0) fail(`${kind} "${slug}" has the unknown option "${unknownKey}". The options are ${names.join(", ")}.`);
45
64
  const flag = (name) => readFlag(kind, slug, name, settings[name]);
46
65
  const read = flag("read") ?? true;
47
- const write = flag("write") ?? true;
66
+ const write = flag("write") ?? read;
48
67
  const publish = flag("publish");
49
68
  const hasDrafts = hasDraftsEnabled(config);
50
69
  if (publish === false && write && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so every write goes live. Set write: false or remove publish: false.`);
51
70
  if (publish === true && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so there is nothing to publish. Enable versions.drafts or remove publish: true.`);
71
+ if (!read && (flag("write") === true || publish === true)) fail(`${kind} "${slug}" has read: false but write or publish on. Writing and publishing need read.`);
52
72
  if (publish === true && !write) fail(`${kind} "${slug}" has publish: true but write: false. Publishing follows write.`);
73
+ const deleting = normalizeDelete(kind, config, settings["delete"]);
74
+ if (!read && deleting.delete) fail(`${kind} "${slug}" has read: false but delete on. Deleting needs read.`);
53
75
  return {
54
76
  read,
55
- write: write ? publish === false ? "draft" : "live" : false,
77
+ write,
78
+ publish: write && publish !== false && hasDrafts,
79
+ liveWrite: write && !hasDrafts,
56
80
  hasDrafts,
57
- hasVersions: read && Boolean(config.versions)
81
+ hasVersions: read && Boolean(config.versions),
82
+ ...deleting
58
83
  };
59
84
  };
60
- const assertGlobalExposable = (global) => {
61
- if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
62
- };
63
- const normalizeCollections = (config, options, apiKeysSlug) => {
64
- const collections = config.collections ?? [];
85
+ const bySlug = (configs) => new Map(configs.map((config) => [config.slug, config]));
86
+ const normalizeEntities = (kind, configs, entries, apiKeysSlug) => {
65
87
  const fieldNames = /* @__PURE__ */ new Set();
66
- return Object.entries(options.collections).flatMap(([slug, raw]) => {
88
+ return Object.entries(entries).flatMap(([slug, raw]) => {
67
89
  if (raw === void 0) return [];
68
- const collection = collections.find((candidate) => candidate.slug === slug);
69
- if (!collection) return fail(`Exposed collection "${slug}" does not exist.`);
70
- assertExposable(collection, apiKeysSlug);
90
+ const config = configs.get(slug);
91
+ if (!config) return fail(`Exposed ${kind.toLowerCase()} "${slug}" does not exist.`);
92
+ assertExposable(kind, config, apiKeysSlug);
71
93
  const normalized = {
72
94
  slug,
73
- ...normalizeCapabilities("Collection", collection, raw),
74
- isUpload: Boolean(collection.upload),
95
+ ...normalizeCapabilities(kind, config, raw),
96
+ isUpload: "upload" in config && Boolean(config.upload),
75
97
  fieldName: toCamelCase(slug)
76
98
  };
77
- if (normalized.write !== false) {
78
- if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
79
- assertWritable("Collection", collection, normalized.write);
99
+ if (normalized.write) {
100
+ if ("timestamps" in config && config.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
101
+ assertWritable(kind, config, normalized.publish);
80
102
  }
81
- if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
82
- fieldNames.add(normalized.fieldName);
83
- return [normalized];
84
- });
85
- };
86
- const normalizeGlobals = (config, options) => {
87
- const globals = config.globals ?? [];
88
- const fieldNames = /* @__PURE__ */ new Set();
89
- return Object.entries(options.globals ?? {}).flatMap(([slug, raw]) => {
90
- if (raw === void 0) return [];
91
- const global = globals.find((candidate) => candidate.slug === slug);
92
- if (!global) return fail(`Exposed global "${slug}" does not exist.`);
93
- assertGlobalExposable(global);
94
- const normalized = {
95
- slug,
96
- ...normalizeCapabilities("Global", global, raw),
97
- isUpload: false,
98
- fieldName: toCamelCase(slug)
99
- };
100
- if (normalized.write !== false) assertWritable("Global", global, normalized.write);
101
- if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
103
+ if (fieldNames.has(normalized.fieldName)) fail(`${kind} "${slug}" maps to capability field "${normalized.fieldName}", which another exposed ${kind.toLowerCase()} already uses.`);
102
104
  fieldNames.add(normalized.fieldName);
103
105
  return [normalized];
104
106
  });
105
107
  };
106
- const assertUserCollection = (config, slug) => {
107
- const collection = (config.collections ?? []).find((candidate) => candidate.slug === slug);
108
+ const assertUserCollection = (collections, slug) => {
109
+ const collection = collections.get(slug);
108
110
  if (!collection) fail(`User collection "${slug}" does not exist.`);
109
- else if (!collection.auth) fail(`User collection "${slug}" is not an auth collection.`);
111
+ else if (!("auth" in collection && collection.auth)) fail(`User collection "${slug}" is not an auth collection.`);
110
112
  };
111
- const assertTools = (tools) => {
113
+ const assertTools = (tools, reserved) => {
112
114
  const names = /* @__PURE__ */ new Set();
113
115
  for (const tool of tools) {
114
116
  if (!TOOL_NAME_PATTERN.test(tool.name)) fail(`Tool name "${tool.name}" must match ${String(TOOL_NAME_PATTERN)}.`);
115
- if (BUILTIN_TOOL_NAMES.includes(tool.name)) fail(`Tool name "${tool.name}" is reserved for a builtin tool.`);
117
+ if (reserved.includes(tool.name)) fail(`Tool name "${tool.name}" is reserved for a builtin tool.`);
116
118
  if (names.has(tool.name)) fail(`Tool name "${tool.name}" is used twice.`);
117
119
  names.add(tool.name);
118
120
  }
@@ -127,27 +129,36 @@ const normalizeLimits = (limits) => {
127
129
  maxDepth
128
130
  };
129
131
  };
130
- /** Every problem is an `InvalidConfiguration`, so it fails at startup. */ const normalizeOptions = (config, options) => {
132
+ /**
133
+ * Every problem is an `InvalidConfiguration`, so it fails at startup.
134
+ * `reservedToolNames` are the builtin tool names custom tools must not reuse.
135
+ */ const normalizeOptions = (config, options, reservedToolNames) => {
131
136
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
132
137
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
133
- if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
134
- assertUserCollection(config, userCollection);
138
+ const collectionConfigs = bySlug(config.collections ?? []);
139
+ if (collectionConfigs.has(apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
140
+ assertUserCollection(collectionConfigs, userCollection);
135
141
  const tools = options.tools ?? [];
136
- assertTools(tools);
142
+ assertTools(tools, reservedToolNames);
143
+ const collections = normalizeEntities("Collection", collectionConfigs, options.collections, apiKeysSlug);
144
+ const confirmations = collections.some((entity) => entity.delete);
145
+ if (confirmations && options.auth?.resolve) fail("delete needs approval on the API key document, which a custom auth.resolve does not support. Remove delete: true or auth.resolve.");
137
146
  return {
138
- collections: normalizeCollections(config, options, apiKeysSlug),
139
- globals: normalizeGlobals(config, options),
147
+ collections,
148
+ globals: normalizeEntities("Global", bySlug(config.globals ?? []), options.globals ?? {}, apiKeysSlug),
140
149
  userCollection,
141
150
  apiKeysSlug,
142
151
  endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
143
152
  setupGuide: options.apiKeys?.setupGuide ?? true,
144
153
  limits: normalizeLimits(options.limits),
145
154
  tools,
155
+ confirmations,
146
156
  auth: options.auth,
147
157
  serverInfo: {
148
158
  name: options.serverInfo?.name ?? "payloadcms-mcpx",
149
- version: options.serverInfo?.version ?? "1.0.0"
150
- }
159
+ version: options.serverInfo?.version ?? "2.0.0"
160
+ },
161
+ diagnostics: options.diagnostics ?? true
151
162
  };
152
163
  };
153
164
  //#endregion
package/dist/plugin.d.mts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { McpxPluginOptions } from "./types.mjs";
2
2
  //#region src/plugin.d.ts
3
3
  /**
4
- * Mounts the MCP endpoint, adds the API key collection and installs the
5
- * draft guard on every collection and global.
4
+ * Mounts the MCP, upload and download endpoints, and the confirmation
5
+ * endpoints where a collection exposes `delete`. Adds the API key collection
6
+ * and installs the draft guard on every collection and global.
6
7
  */
7
8
  export declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
8
9
  //#endregion
package/dist/plugin.mjs CHANGED
@@ -1,17 +1,21 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
+ import { BUILTIN_TOOLS } from "./tools/builtin.mjs";
3
+ import { confirmationEndpoints } from "./endpoint/confirmations.mjs";
2
4
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
5
+ import { createDownloadHandler, createUploadHandler } from "./endpoint/upload.mjs";
3
6
  import { normalizeOptions } from "./options.mjs";
4
7
  import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
8
  import { definePlugin } from "payload";
6
9
  //#region src/plugin.ts
7
10
  /**
8
- * Mounts the MCP endpoint, adds the API key collection and installs the
9
- * draft guard on every collection and global.
11
+ * Mounts the MCP, upload and download endpoints, and the confirmation
12
+ * endpoints where a collection exposes `delete`. Adds the API key collection
13
+ * and installs the draft guard on every collection and global.
10
14
  */ const mcpxPlugin = definePlugin({
11
15
  slug: "@abinnovision/payloadcms-mcpx",
12
16
  order: 100,
13
17
  plugin: ({ config, plugins: _plugins, ...options }) => {
14
- const normalized = normalizeOptions(config, options);
18
+ const normalized = normalizeOptions(config, options, BUILTIN_TOOLS.map((tool) => tool.name));
15
19
  const apiKeys = createApiKeysCollection(normalized);
16
20
  const apiKeysCollection = options.apiKeys?.overrideCollection?.(apiKeys) ?? apiKeys;
17
21
  return {
@@ -34,7 +38,18 @@ import { definePlugin } from "payload";
34
38
  path: normalized.endpointPath,
35
39
  method: "delete",
36
40
  handler: methodNotAllowed
37
- }
41
+ },
42
+ {
43
+ path: `${normalized.endpointPath}/upload`,
44
+ method: "put",
45
+ handler: createUploadHandler(normalized)
46
+ },
47
+ {
48
+ path: `${normalized.endpointPath}/file`,
49
+ method: "get",
50
+ handler: createDownloadHandler(normalized)
51
+ },
52
+ ...normalized.confirmations ? confirmationEndpoints(normalized) : []
38
53
  ]
39
54
  };
40
55
  }
package/dist/request.mjs CHANGED
@@ -3,5 +3,9 @@
3
3
  * Whether `req` came through the MCP endpoint, which stamps `req.context.mcpx`.
4
4
  * The stamp follows `req` into every local API call, including custom tools.
5
5
  */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
6
+ /**
7
+ * The origin links to this server use: `serverURL` when set, else that of the
8
+ * MCP request.
9
+ */ const publicOrigin = (req) => req.payload.config.serverURL || new URL(req.url ?? "").origin;
6
10
  //#endregion
7
- export { isMcpxRequest };
11
+ export { isMcpxRequest, publicOrigin };
package/dist/result.mjs CHANGED
@@ -1,4 +1,14 @@
1
1
  //#region src/result.ts
2
+ /**
3
+ * The JSON of a result's first text block. Other text comes back as `{ text }`.
4
+ */ const parseResult = (result) => {
5
+ const [first] = result.content;
6
+ try {
7
+ return first?.type === "text" ? JSON.parse(first.text) : {};
8
+ } catch {
9
+ return { text: first?.type === "text" ? first.text : void 0 };
10
+ }
11
+ };
2
12
  const jsonResult = (value) => ({ content: [{
3
13
  type: "text",
4
14
  text: JSON.stringify(value)
@@ -17,4 +27,4 @@ const jsonResult = (value) => ({ content: [{
17
27
  isError: true
18
28
  });
19
29
  //#endregion
20
- export { errorResult, jsonResult };
30
+ export { errorResult, jsonResult, parseResult };
@@ -1,5 +1,5 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
- import { RESERVED_FIELD_NAMES, ROW_KEYS, blockOf, blockSlugsOf, isAdminHidden, schemaOf } from "./walk.mjs";
2
+ import { RESERVED_FIELD_NAMES, ROW_KEYS, adminFlag, blockForRow, blockOf, blockSlugsOf, schemaOf } from "./walk.mjs";
3
3
  //#region src/schema/admin-hidden.ts
4
4
  const UPLOAD_BASE_FIELDS = /* @__PURE__ */ new Set([
5
5
  "filename",
@@ -22,7 +22,7 @@ const stripFields = (config, fields, doc, exempt = NONE) => {
22
22
  const result = { ...doc };
23
23
  for (const field of fields) {
24
24
  if (!("name" in field) || exempt.has(field.name) || isPayloadKey(field.name) || !Object.hasOwn(result, field.name)) continue;
25
- if (isAdminHidden(field)) Reflect.deleteProperty(result, field.name);
25
+ if (adminFlag(field, "hidden")) Reflect.deleteProperty(result, field.name);
26
26
  else result[field.name] = stripValue(config, field, result[field.name]);
27
27
  }
28
28
  return result;
@@ -45,7 +45,7 @@ const stripValue = (config, field, value) => {
45
45
  case "tab": return isPlainObject(value) ? stripFields(config, field.flattenedFields, value) : value;
46
46
  case "array": return mapEach(value, (row) => isPlainObject(row) ? stripFields(config, field.flattenedFields, row) : row);
47
47
  case "blocks": return mapEach(value, (row) => {
48
- const block = isPlainObject(row) && typeof row["blockType"] === "string" ? blockOf(config, field, row["blockType"]) : void 0;
48
+ const block = blockForRow(config, field, row);
49
49
  return block && isPlainObject(row) ? stripFields(config, block.flattenedFields, row) : row;
50
50
  });
51
51
  case "relationship":
@@ -76,7 +76,7 @@ const stripValue = (config, field, value) => {
76
76
  const matches = fields.filter((field) => "name" in field && field.name === segment);
77
77
  if (matches.length === 0) return locales.includes(segment) ? walk(fields, rest) : false;
78
78
  return matches.some((field) => {
79
- if (isAdminHidden(field)) return true;
79
+ if (adminFlag(field, "hidden")) return true;
80
80
  if (field.type === "group" || field.type === "tab" || field.type === "array") return walk(field.flattenedFields, rest);
81
81
  return field.type === "blocks" && blockSlugsOf(field).some((slug) => {
82
82
  const block = blockOf(config, field, slug);
@@ -1,25 +1,22 @@
1
+ import { SchemaError } from "./errors.mjs";
1
2
  import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
2
3
  import { joinPath, splitPath } from "./path.mjs";
3
4
  import { translateAny } from "../i18n.mjs";
4
- import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, schemaOf } from "./walk.mjs";
5
- import { SchemaError } from "./errors.mjs";
5
+ import { blockSlugsOf, describeFields, findFieldAt, longestMatch, requireBlock, schemaOf } from "./walk.mjs";
6
6
  //#region src/schema/describe.ts
7
- const matchSchemaSegments = (descriptors, remaining) => descriptors.map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
8
7
  const stepThroughBlocks = ({ config, fields, match, remaining }) => {
9
- const field = findBlocksField(fields, match);
8
+ const field = findFieldAt(fields, match, "blocks");
10
9
  if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
11
10
  const slug = remaining.at(match.length);
12
11
  if (slug === void 0) throw new SchemaError(`"${joinPath(match)}" is a blocks field; append one of: ${blockSlugsOf(field).join(", ")}`);
13
- const block = blockOf(config, field, slug);
14
- if (!block) throw new SchemaError(`"${slug}" is not allowed at "${joinPath(match)}". Allowed: ${blockSlugsOf(field).join(", ")}`);
15
12
  return {
16
13
  blockType: slug,
17
- fields: block.flattenedFields,
14
+ fields: requireBlock(config, field, slug, joinPath(match)).flattenedFields,
18
15
  rest: remaining.slice(match.length + 1)
19
16
  };
20
17
  };
21
18
  const stepThroughLexical = ({ config, fields, match, remaining }) => {
22
- const field = findRichTextField(fields, match);
19
+ const field = findFieldAt(fields, match, "richText");
23
20
  if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
24
21
  const available = subSchemaNodeTypes(field).join(", ") || "none";
25
22
  const nodeType = remaining.at(match.length);
@@ -32,13 +29,10 @@ const stepThroughLexical = ({ config, fields, match, remaining }) => {
32
29
  rest: remaining.slice(match.length + 1)
33
30
  };
34
31
  const slug = remaining.at(match.length + 1);
35
- const slugs = blockSlugsOf(sub.blocksField).join(", ");
36
- if (slug === void 0) throw new SchemaError(`"${reached}" selects a block; append one of: ${slugs}`);
37
- const block = blockOf(config, sub.blocksField, slug);
38
- if (!block) throw new SchemaError(`"${slug}" is not allowed at "${reached}". Allowed: ${slugs}`);
32
+ if (slug === void 0) throw new SchemaError(`"${reached}" selects a block; append one of: ${blockSlugsOf(sub.blocksField).join(", ")}`);
39
33
  return {
40
34
  blockType: slug,
41
- fields: block.flattenedFields,
35
+ fields: requireBlock(config, sub.blocksField, slug, reached).flattenedFields,
42
36
  rest: remaining.slice(match.length + 2)
43
37
  };
44
38
  };
@@ -48,15 +42,15 @@ const fieldsAtSchemaPath = (config, target, schemaPath) => {
48
42
  let remaining = splitPath(schemaPath);
49
43
  while (remaining.length > 0) {
50
44
  const descendable = describeFields(fields).filter((descriptor) => descriptor.type === "blocks" || descriptor.type === "richText");
51
- const match = matchSchemaSegments(descendable, remaining);
52
- if (!match) throw new SchemaError(`"${joinPath(remaining)}" does not address a blocks or rich text field. Available here: ${descendable.map((descriptor) => descriptor.path).join(", ") || "none"}`);
45
+ const found = longestMatch(descendable, remaining, (part, segment) => part === segment);
46
+ if (!found) throw new SchemaError(`"${joinPath(remaining)}" does not address a blocks or rich text field. Available here: ${descendable.map((descriptor) => descriptor.path).join(", ") || "none"}`);
53
47
  const at = {
54
48
  config,
55
49
  fields,
56
- match,
50
+ match: found.parts,
57
51
  remaining
58
52
  };
59
- const step = findBlocksField(fields, match) === void 0 ? stepThroughLexical(at) : stepThroughBlocks(at);
53
+ const step = found.descriptor.type === "blocks" ? stepThroughBlocks(at) : stepThroughLexical(at);
60
54
  blockType = step.blockType;
61
55
  fields = step.fields;
62
56
  remaining = step.rest;
@@ -69,7 +63,7 @@ const fieldsAtSchemaPath = (config, target, schemaPath) => {
69
63
  const branchesOf = (fields, descriptor, schemaPath) => {
70
64
  const base = `${schemaPath}${descriptor.path}`;
71
65
  if (descriptor.type === "richText") {
72
- const field = findRichTextField(fields, splitPath(descriptor.path));
66
+ const field = findFieldAt(fields, splitPath(descriptor.path), "richText");
73
67
  if (!field) return [];
74
68
  return subSchemaNodeTypes(field).flatMap((nodeType) => {
75
69
  const sub = lexicalSubSchema(field, nodeType);
@@ -1,11 +1,10 @@
1
- import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
2
- import { JSON_POINTER_PATTERN, isIndexSegment, joinPath, pointerFromPayloadPath, prototypeSegmentProblem, splitPath } from "./path.mjs";
3
- import { RESERVED_FIELD_NAMES, ROW_KEYS, blockOf, blockSlugsOf, describeAddressableFields, describeFields, descriptorsUnder, findBlocksField, findRichTextField, isAdminHidden, schemaOf } from "./walk.mjs";
4
- import { addressesAdminHidden, stripAdminHidden } from "./admin-hidden.mjs";
5
1
  import { SchemaError } from "./errors.mjs";
2
+ import { editorRoot, nodePropertiesFor } from "./lexical.mjs";
3
+ import { JSON_POINTER_PATTERN, isIndexSegment, parentPointer, pointerFromPayloadPath, prototypeSegmentProblem, splitPath } from "./path.mjs";
4
+ import { RESERVED_FIELD_NAMES, ROW_KEYS, blockForRow, classifyKey, descriptorsUnder, findFieldAt } from "./walk.mjs";
5
+ import { addressesAdminHidden, stripAdminHidden } from "./admin-hidden.mjs";
6
6
  import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
7
- import { resolveLexicalPointer } from "./lexical-pointer.mjs";
8
7
  import { lexicalOutline } from "./outline.mjs";
9
8
  import { resolveDataPointer } from "./pointer.mjs";
10
9
  import { EMPTY_ROOT, validateWriteValue } from "./shape.mjs";
11
- export { EMPTY_ROOT, JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, ROW_KEYS, SchemaError, addressesAdminHidden, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, descriptorsUnder, findBlocksField, findRichTextField, isAdminHidden, isIndexSegment, joinPath, lexicalOutline, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, nodePropertiesFor, pointerFromPayloadPath, propertyProblem, prototypeSegmentProblem, reachableSchemaPaths, resolveDataPointer, resolveLexicalPointer, rootProblems, schemaOf, splitPath, stripAdminHidden, subSchemaNodeTypes, validateWriteValue };
10
+ export { EMPTY_ROOT, JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, ROW_KEYS, SchemaError, addressesAdminHidden, blockForRow, classifyKey, descriptorsUnder, editorRoot, findFieldAt, isIndexSegment, lexicalOutline, nodeDescriber, nodePropertiesFor, parentPointer, pointerFromPayloadPath, prototypeSegmentProblem, reachableSchemaPaths, resolveDataPointer, splitPath, stripAdminHidden, validateWriteValue };