@abinnovision/payloadcms-mcpx 2.0.0-beta.19 → 2.0.0-beta.20

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 (63) hide show
  1. package/dist/admin/capability-matrix.mjs +213 -316
  2. package/dist/admin/capability-toggles.mjs +24 -31
  3. package/dist/admin/confirmations-panel.mjs +61 -71
  4. package/dist/admin/confirmations.mjs +2 -1
  5. package/dist/admin/endpoint-url.mjs +7 -0
  6. package/dist/admin/setup-guide.mjs +2 -1
  7. package/dist/api-keys/capability-matrix.d.mts +3 -6
  8. package/dist/api-keys/capability-matrix.mjs +9 -48
  9. package/dist/api-keys/collection.mjs +2 -5
  10. package/dist/api-keys/confirmation-view.mjs +2 -1
  11. package/dist/api-keys/fields.mjs +33 -55
  12. package/dist/auth/resolve.mjs +29 -10
  13. package/dist/capabilities.d.mts +2 -0
  14. package/dist/capabilities.mjs +62 -19
  15. package/dist/endpoint/confirmations.mjs +14 -14
  16. package/dist/endpoint/handler.mjs +6 -22
  17. package/dist/endpoint/http.mjs +27 -0
  18. package/dist/endpoint/upload.mjs +11 -43
  19. package/dist/grants/confirmation.mjs +11 -42
  20. package/dist/grants/grant.mjs +58 -40
  21. package/dist/guards.mjs +8 -1
  22. package/dist/index.d.mts +2 -2
  23. package/dist/index.mjs +1 -1
  24. package/dist/options.mjs +34 -54
  25. package/dist/plugin.mjs +2 -1
  26. package/dist/request.mjs +5 -1
  27. package/dist/result.mjs +11 -1
  28. package/dist/schema/admin-hidden.mjs +4 -4
  29. package/dist/schema/describe.mjs +12 -18
  30. package/dist/schema/index.mjs +5 -6
  31. package/dist/schema/lexical-pointer.mjs +8 -8
  32. package/dist/schema/lexical.mjs +15 -13
  33. package/dist/schema/outline.mjs +4 -5
  34. package/dist/schema/path.mjs +4 -1
  35. package/dist/schema/pointer.mjs +11 -17
  36. package/dist/schema/shape.mjs +38 -50
  37. package/dist/schema/walk.mjs +57 -18
  38. package/dist/tools/create-document.mjs +16 -44
  39. package/dist/tools/delete-document.mjs +14 -17
  40. package/dist/tools/describe-schema.mjs +7 -5
  41. package/dist/tools/document.mjs +130 -44
  42. package/dist/tools/duplicate-document.mjs +11 -10
  43. package/dist/tools/find-documents.mjs +15 -20
  44. package/dist/tools/find-versions.mjs +10 -14
  45. package/dist/tools/get-document.mjs +8 -12
  46. package/dist/tools/links.mjs +2 -1
  47. package/dist/tools/list-capabilities.mjs +2 -1
  48. package/dist/tools/patch-document.mjs +12 -44
  49. package/dist/tools/publish-document.mjs +9 -27
  50. package/dist/tools/query-paths.mjs +1 -1
  51. package/dist/tools/run-confirmed.mjs +38 -50
  52. package/dist/tools/shared.mjs +43 -41
  53. package/dist/tools/validate-document.mjs +4 -6
  54. package/dist/tools/versions.mjs +11 -6
  55. package/dist/types.d.mts +12 -26
  56. package/dist/upload/file.mjs +10 -18
  57. package/dist/write/draft-guard.mjs +20 -20
  58. package/dist/write/patch.mjs +4 -4
  59. package/dist/write/publish-intent.mjs +22 -28
  60. package/dist/write/row-ids.mjs +3 -1
  61. package/dist/write/write-data.mjs +18 -31
  62. package/package.json +5 -5
  63. package/dist/builtin-tool-names.mjs +0 -19
@@ -1,14 +1,21 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
- import { hashApiKey } from "../api-keys/key.mjs";
2
+ import { generateApiKey, hashApiKey } from "../api-keys/key.mjs";
3
3
  import { APIError, ValidationError } from "payload";
4
- import crypto from "node:crypto";
5
4
  //#region src/grants/grant.ts
6
5
  const GRANT_TTL_MS = 3e5;
7
6
  const GRANTS_PER_KEY = 10;
8
- const GRANT_ID = /^[\w-]{43}$/;
7
+ const ROW_ID = /^[\w-]{43}$/;
9
8
  const NO_REQ = {};
10
- const grantPrefix = (apiKeyId) => `mcpx-grant:${String(apiKeyId)}:`;
11
- const usedPrefix = (apiKeyId) => `mcpx-grant-used:${String(apiKeyId)}:`;
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);
12
19
  const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ? data["exp"] : 0;
13
20
  /**
14
21
  * The KV collection slug, when Payload's database KV adapter backs it with a
@@ -49,12 +56,12 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
49
56
  req: NO_REQ
50
57
  });
51
58
  if (rows.filter((row) => row.key.startsWith(args.prefix) && expOf(row.data) > now).length >= args.cap) throw new APIError(args.capMessage, 429);
52
- const id = crypto.randomBytes(32).toString("base64url");
59
+ const id = generateApiKey();
53
60
  const exp = now + args.ttlMs;
54
61
  await payload.db.create({
55
62
  collection: slug,
56
63
  data: {
57
- key: `${args.prefix}${hashApiKey(payload.secret, id)}`,
64
+ key: `${args.prefix}${rowHandle(payload, id)}`,
58
65
  data: {
59
66
  ...args.data,
60
67
  exp
@@ -68,52 +75,63 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
68
75
  };
69
76
  };
70
77
  /**
71
- * Stores a grant and returns its id. A key may hold at most
72
- * {@link GRANTS_PER_KEY} grants.
73
- */ const issueGrant = async (payload, slug, grant) => {
74
- const { id, exp } = await issueRow(payload, slug, {
75
- prefix: grantPrefix(grant.apiKeyId),
76
- usedPrefix: usedPrefix(grant.apiKeyId),
77
- cap: GRANTS_PER_KEY,
78
- 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.`,
79
- ttlMs: GRANT_TTL_MS,
80
- data: grant
81
- });
82
- return {
83
- grantId: id,
84
- exp
85
- };
86
- };
87
- /**
88
- * Loads the grant for `grantId` and marks it used, or returns `undefined` for
89
- * a malformed, unknown, expired or used one. The marker is a unique-key
90
- * insert, so of two concurrent claims exactly one succeeds. A malformed id
91
- * is refused before any database read.
92
- */ const claimGrant = async (payload, slug, grantId) => {
93
- if (!GRANT_ID.test(grantId)) return;
94
- const hash = hashApiKey(payload.secret, grantId);
95
- const row = (await findRows(payload, slug, [hash])).find((candidate) => candidate.key.startsWith("mcpx-grant:") && candidate.key.endsWith(`:${hash}`));
96
- const grant = row?.data;
97
- if (!row || !isPlainObject(grant) || row.key !== `${grantPrefix(grant.apiKeyId)}${hash}` || expOf(grant) <= Date.now()) return;
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) => {
98
81
  try {
99
82
  await payload.db.create({
100
83
  collection: slug,
101
84
  data: {
102
- key: `${usedPrefix(grant.apiKeyId)}${hash}`,
103
- data: { exp: grant.exp }
85
+ key: `${prefixes.usedPrefix}${handle}`,
86
+ data: { exp }
104
87
  },
105
88
  req: NO_REQ
106
89
  });
107
90
  } catch (error) {
108
- if (error instanceof ValidationError) return;
91
+ if (error instanceof ValidationError) return false;
109
92
  throw error;
110
93
  }
111
94
  await payload.db.deleteOne({
112
95
  collection: slug,
113
- where: { key: { equals: row.key } },
96
+ where: { key: { equals: `${prefixes.prefix}${handle}` } },
114
97
  req: NO_REQ
115
98
  });
116
- return grant;
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;
117
135
  };
118
136
  //#endregion
119
- export { GRANT_ID, NO_REQ, claimGrant, expOf, grantKvSlug, issueGrant, issueRow, rowsWith };
137
+ export { NO_REQ, ROW_ID, claimGrant, claimRow, expOf, grantContext, grantKvSlug, issueGrant, issueRow, rowHandle, rowPrefixes, rowsWith };
package/dist/guards.mjs CHANGED
@@ -1,7 +1,14 @@
1
1
  //#region src/guards.ts
2
2
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
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
+ /**
4
7
  * An own property only, so a client-chosen key never reads the prototype.
5
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));
6
13
  //#endregion
7
- export { isPlainObject, ownValue };
14
+ export { definedProps, isPlainObject, ownValue, propOf };
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,6 +1,5 @@
1
1
  import { isPlainObject } from "./guards.mjs";
2
2
  import "./version.mjs";
3
- import { BUILTIN_TOOL_NAMES } from "./builtin-tool-names.mjs";
4
3
  import { InvalidConfiguration } from "payload";
5
4
  import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
6
5
  //#region src/options.ts
@@ -15,10 +14,10 @@ const fail = (message) => {
15
14
  /**
16
15
  * The same transform the stock MCP plugin uses to derive field names.
17
16
  */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
18
- const assertExposable = (collection, apiKeysSlug) => {
19
- const { slug } = collection;
20
- if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
21
- if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
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.`);
22
21
  };
23
22
  const OPTION_NAMES = [
24
23
  "read",
@@ -40,8 +39,8 @@ const readFlag = (kind, slug, name, value) => {
40
39
  const moved = name === "write" && typeof value === "string" ? REMOVED_WRITE_MODES[value] : void 0;
41
40
  return fail(`${kind} "${slug}" has ${name}: ${JSON.stringify(value)}. ${moved ?? "Use true or false."}`);
42
41
  };
43
- const assertWritable = (kind, config, write) => {
44
- 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.`);
45
44
  };
46
45
  const normalizeDelete = (kind, config, value) => {
47
46
  if (value !== "unattended") return {
@@ -75,68 +74,47 @@ const normalizeCapabilities = (kind, config, raw) => {
75
74
  if (!read && deleting.delete) fail(`${kind} "${slug}" has read: false but delete on. Deleting needs read.`);
76
75
  return {
77
76
  read,
78
- write: write ? publish === false ? "draft" : "live" : false,
77
+ write,
78
+ publish: write && publish !== false && hasDrafts,
79
+ liveWrite: write && !hasDrafts,
79
80
  hasDrafts,
80
81
  hasVersions: read && Boolean(config.versions),
81
82
  ...deleting
82
83
  };
83
84
  };
84
- const assertGlobalExposable = (global) => {
85
- if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
86
- };
87
- const normalizeCollections = (config, options, apiKeysSlug) => {
88
- const collections = config.collections ?? [];
85
+ const bySlug = (configs) => new Map(configs.map((config) => [config.slug, config]));
86
+ const normalizeEntities = (kind, configs, entries, apiKeysSlug) => {
89
87
  const fieldNames = /* @__PURE__ */ new Set();
90
- return Object.entries(options.collections).flatMap(([slug, raw]) => {
88
+ return Object.entries(entries).flatMap(([slug, raw]) => {
91
89
  if (raw === void 0) return [];
92
- const collection = collections.find((candidate) => candidate.slug === slug);
93
- if (!collection) return fail(`Exposed collection "${slug}" does not exist.`);
94
- 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);
95
93
  const normalized = {
96
94
  slug,
97
- ...normalizeCapabilities("Collection", collection, raw),
98
- isUpload: Boolean(collection.upload),
95
+ ...normalizeCapabilities(kind, config, raw),
96
+ isUpload: "upload" in config && Boolean(config.upload),
99
97
  fieldName: toCamelCase(slug)
100
98
  };
101
- if (normalized.write !== false) {
102
- if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
103
- 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);
104
102
  }
105
- if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
106
- fieldNames.add(normalized.fieldName);
107
- return [normalized];
108
- });
109
- };
110
- const normalizeGlobals = (config, options) => {
111
- const globals = config.globals ?? [];
112
- const fieldNames = /* @__PURE__ */ new Set();
113
- return Object.entries(options.globals ?? {}).flatMap(([slug, raw]) => {
114
- if (raw === void 0) return [];
115
- const global = globals.find((candidate) => candidate.slug === slug);
116
- if (!global) return fail(`Exposed global "${slug}" does not exist.`);
117
- assertGlobalExposable(global);
118
- const normalized = {
119
- slug,
120
- ...normalizeCapabilities("Global", global, raw),
121
- isUpload: false,
122
- fieldName: toCamelCase(slug)
123
- };
124
- if (normalized.write !== false) assertWritable("Global", global, normalized.write);
125
- 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.`);
126
104
  fieldNames.add(normalized.fieldName);
127
105
  return [normalized];
128
106
  });
129
107
  };
130
- const assertUserCollection = (config, slug) => {
131
- const collection = (config.collections ?? []).find((candidate) => candidate.slug === slug);
108
+ const assertUserCollection = (collections, slug) => {
109
+ const collection = collections.get(slug);
132
110
  if (!collection) fail(`User collection "${slug}" does not exist.`);
133
- 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.`);
134
112
  };
135
- const assertTools = (tools) => {
113
+ const assertTools = (tools, reserved) => {
136
114
  const names = /* @__PURE__ */ new Set();
137
115
  for (const tool of tools) {
138
116
  if (!TOOL_NAME_PATTERN.test(tool.name)) fail(`Tool name "${tool.name}" must match ${String(TOOL_NAME_PATTERN)}.`);
139
- 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.`);
140
118
  if (names.has(tool.name)) fail(`Tool name "${tool.name}" is used twice.`);
141
119
  names.add(tool.name);
142
120
  }
@@ -153,19 +131,21 @@ const normalizeLimits = (limits) => {
153
131
  };
154
132
  /**
155
133
  * Every problem is an `InvalidConfiguration`, so it fails at startup.
156
- */ const normalizeOptions = (config, options) => {
134
+ * `reservedToolNames` are the builtin tool names custom tools must not reuse.
135
+ */ const normalizeOptions = (config, options, reservedToolNames) => {
157
136
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
158
137
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
159
- if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
160
- 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);
161
141
  const tools = options.tools ?? [];
162
- assertTools(tools);
163
- const collections = normalizeCollections(config, options, apiKeysSlug);
142
+ assertTools(tools, reservedToolNames);
143
+ const collections = normalizeEntities("Collection", collectionConfigs, options.collections, apiKeysSlug);
164
144
  const confirmations = collections.some((entity) => entity.delete);
165
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.");
166
146
  return {
167
147
  collections,
168
- globals: normalizeGlobals(config, options),
148
+ globals: normalizeEntities("Global", bySlug(config.globals ?? []), options.globals ?? {}, apiKeysSlug),
169
149
  userCollection,
170
150
  apiKeysSlug,
171
151
  endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
package/dist/plugin.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
+ import { BUILTIN_TOOLS } from "./tools/builtin.mjs";
2
3
  import { confirmationEndpoints } from "./endpoint/confirmations.mjs";
3
4
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
4
5
  import { createDownloadHandler, createUploadHandler } from "./endpoint/upload.mjs";
@@ -14,7 +15,7 @@ import { definePlugin } from "payload";
14
15
  slug: "@abinnovision/payloadcms-mcpx",
15
16
  order: 100,
16
17
  plugin: ({ config, plugins: _plugins, ...options }) => {
17
- const normalized = normalizeOptions(config, options);
18
+ const normalized = normalizeOptions(config, options, BUILTIN_TOOLS.map((tool) => tool.name));
18
19
  const apiKeys = createApiKeysCollection(normalized);
19
20
  const apiKeysCollection = options.apiKeys?.overrideCollection?.(apiKeys) ?? apiKeys;
20
21
  return {
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 };
@@ -1,8 +1,8 @@
1
- import { isPlainObject } from "../guards.mjs";
2
- import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
1
+ import { isPlainObject, propOf } from "../guards.mjs";
2
+ import { SchemaError } from "./errors.mjs";
3
+ import { editorRoot, lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
3
4
  import { isIndexSegment, joinPath } from "./path.mjs";
4
5
  import { blockOf, blockSlugsOf } from "./walk.mjs";
5
- import { SchemaError } from "./errors.mjs";
6
6
  //#region src/schema/lexical-pointer.ts
7
7
  const stepIntoFields = (at) => {
8
8
  const { addedValue, config, field, node, nodeType, rest } = at;
@@ -15,8 +15,7 @@ const stepIntoFields = (at) => {
15
15
  kind: "fields",
16
16
  rest
17
17
  };
18
- const added = addedValue?.fields;
19
- const slug = (isPlainObject(data) ? data["blockType"] : void 0) ?? (isPlainObject(added) ? added["blockType"] : void 0);
18
+ const slug = propOf(data, "blockType") ?? propOf(propOf(addedValue, "fields"), "blockType");
20
19
  if (slug === void 0) throw new SchemaError(`Cannot tell which block a "${nodeType}" node holds. Supply a "blockType" on the value, one of: ${blockSlugsOf(sub.blocksField).join(", ")}`);
21
20
  if (typeof slug !== "string") throw new SchemaError(`"blockType" must be a string naming a block in a "${nodeType}" node here. Allowed: ${blockSlugsOf(sub.blocksField).join(", ")}`);
22
21
  const block = blockOf(config, sub.blocksField, slug);
@@ -41,10 +40,11 @@ const stepIntoFields = (at) => {
41
40
  descriptor,
42
41
  field
43
42
  };
44
- if (!isPlainObject(state) || !isPlainObject(state["root"])) throw new SchemaError(`"${descriptor.path}" holds no editor state yet. Write the whole field once, then address positions inside it.`);
43
+ const root = editorRoot(state);
44
+ if (!root) throw new SchemaError(`"${descriptor.path}" holds no editor state yet. Write the whole field once, then address positions inside it.`);
45
45
  const [entry, ...rest] = at.segments;
46
46
  if (entry !== "root") throw new SchemaError(`"${String(entry)}" is not a position in a rich text field. An editor state is entered at "root", e.g. "${descriptor.path}/root/children/0". getDocument with "outline" lists every position this field holds.`);
47
- let node = state["root"];
47
+ let node = root;
48
48
  let nodeType = "root";
49
49
  let segments = rest;
50
50
  let walked = ["root"];
@@ -73,7 +73,7 @@ const stepIntoFields = (at) => {
73
73
  if (!isIndexSegment(index)) throw new SchemaError(`"${descriptor.path}${joinPath([...walked, "children"])}" is a list; "${index}" is not an index. getDocument with "outline" reports the pointer of each node in it.`);
74
74
  const children = node?.["children"];
75
75
  const child = Array.isArray(children) && index !== "-" ? children[Number(index)] : void 0;
76
- const type = isPlainObject(child) ? child["type"] : addedValue?.type;
76
+ const type = isPlainObject(child) ? child["type"] : propOf(addedValue, "type");
77
77
  if (typeof type !== "string") {
78
78
  if (beyond.length === 0) return {
79
79
  kind: "position",
@@ -1,4 +1,4 @@
1
- import { isPlainObject, ownValue } from "../guards.mjs";
1
+ import { isPlainObject, ownValue, propOf } from "../guards.mjs";
2
2
  import { flattenAllFields } from "payload";
3
3
  //#region src/schema/lexical.ts
4
4
  const LEXICAL_CORE_NODES = [
@@ -148,6 +148,16 @@ const UNIVERSAL_PROPERTIES = { version: "number" };
148
148
  }
149
149
  };
150
150
  /**
151
+ * The `root` node of an editor state, if it has one.
152
+ */ const editorRoot = (state) => {
153
+ const root = propOf(state, "root");
154
+ return isPlainObject(root) ? root : void 0;
155
+ };
156
+ const constraintsFor = (type) => ({
157
+ ...UNIVERSAL_PROPERTIES,
158
+ ...ownValue(REQUIRED_NODE_PROPERTIES, type)
159
+ });
160
+ /**
151
161
  * Whether the table already says what a node type's `fields` must be, so the
152
162
  * sub-field walk does not report the same problem twice.
153
163
  */ const constrainsFields = (type) => "fields" in (ownValue(REQUIRED_NODE_PROPERTIES, type) ?? {});
@@ -159,8 +169,7 @@ const describeConstraints = (constraints) => Object.fromEntries(Object.entries(c
159
169
  * {@link UNIVERSAL_PROPERTIES} omits it: the validator finds the entry by
160
170
  * `type` and so never reports it missing, but a client must still write one.
161
171
  */ const nodePropertiesFor = (types) => Object.fromEntries([...new Set(types)].sort().map((type) => [type, describeConstraints(type === "root" ? ROOT_PROPERTIES : {
162
- ...ownValue(REQUIRED_NODE_PROPERTIES, type),
163
- ...UNIVERSAL_PROPERTIES,
172
+ ...constraintsFor(type),
164
173
  type: "string"
165
174
  })]));
166
175
  const check = (node, constraints) => {
@@ -177,10 +186,7 @@ const check = (node, constraints) => {
177
186
  problems.rejected.sort((left, right) => left.property.localeCompare(right.property));
178
187
  return problems;
179
188
  };
180
- const nodeProblems = (node) => check(node, {
181
- ...UNIVERSAL_PROPERTIES,
182
- ...ownValue(REQUIRED_NODE_PROPERTIES, node["type"])
183
- });
189
+ const nodeProblems = (node) => check(node, constraintsFor(node["type"]));
184
190
  /**
185
191
  * The root is the one node Payload describes itself, down to refusing an
186
192
  * unknown property, so it is checked against that description instead of the
@@ -194,11 +200,7 @@ const nodeProblems = (node) => check(node, {
194
200
  * instead of a whole node. Absent where the table says nothing, matching the
195
201
  * node walk: a project's own node may carry any property.
196
202
  */ const propertyProblem = (nodeType, property, value) => {
197
- const constraints = nodeType === "root" ? ROOT_PROPERTIES : {
198
- ...UNIVERSAL_PROPERTIES,
199
- ...ownValue(REQUIRED_NODE_PROPERTIES, nodeType)
200
- };
201
- const constraint = ownValue(constraints, property);
203
+ const constraint = ownValue(nodeType === "root" ? ROOT_PROPERTIES : constraintsFor(nodeType), property);
202
204
  return constraint === void 0 || accepts(constraint, value) ? void 0 : { needs: needs(constraint) };
203
205
  };
204
206
  const NODE_OPTION_SOURCES = [{
@@ -233,4 +235,4 @@ const stringList = (value) => Array.isArray(value) && value.every((entry) => typ
233
235
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
234
236
  };
235
237
  //#endregion
236
- export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes };
238
+ export { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, editorRoot, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes };
@@ -1,5 +1,5 @@
1
1
  import { isPlainObject, ownValue } from "../guards.mjs";
2
- import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
2
+ import { allowedNodeTypes, editorRoot, nodeOptions } from "./lexical.mjs";
3
3
  //#region src/schema/outline.ts
4
4
  const TEXT_PREVIEW_LENGTH = 80;
5
5
  const collectText = (node) => {
@@ -43,12 +43,11 @@ const walk = (node, pointer, options, entries) => {
43
43
  * entry's `pointer` extends it with "/root" and the node's real indices, which
44
44
  * makes it directly usable in a patch operation.
45
45
  */ const lexicalOutline = (state, basePointer, field) => {
46
- if (!isPlainObject(state)) return [];
47
- const root = state["root"];
48
- if (!isPlainObject(root) || !Array.isArray(root["children"])) return [];
46
+ const rootChildren = editorRoot(state)?.["children"];
47
+ if (!Array.isArray(rootChildren)) return [];
49
48
  const options = nodeOptions(field, allowedNodeTypes(field));
50
49
  const entries = [];
51
- root["children"].forEach((child, index) => {
50
+ rootChildren.forEach((child, index) => {
52
51
  if (isPlainObject(child)) walk(child, `${basePointer}/root/children/${String(index)}`, options, entries);
53
52
  });
54
53
  return entries;