@abinnovision/payloadcms-mcpx 2.0.0 → 2.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,8 @@ leave them out. The package is ESM only, like Payload.
30
30
 
31
31
  ## Setup
32
32
 
33
- List the collections and globals the plugin may reach. Anything not listed is not exposed:
33
+ List the collections and globals the plugin may reach. Anything not listed is not exposed, apart
34
+ from Payload's folders, which are readable where an exposed collection uses them (see `folders`):
34
35
 
35
36
  ```ts
36
37
  // payload.config.ts
@@ -94,6 +95,7 @@ Claude Desktop and the MCP Inspector are covered in
94
95
  | `collections.<slug>.publish` | `true` with drafts | Expose `publishDocument`. Needs `versions.drafts` and `write`. |
95
96
  | `collections.<slug>.delete` | `false` | Expose `deleteDocument`. Needs `read`. Each delete is approved in the admin. `"unattended"` also offers trashing without approval; needs `trash`. |
96
97
  | `globals` | `{}` | Globals to expose, with `read`, `write` and `publish`. |
98
+ | `folders` | read-only where used | Payload's folders, where an exposed collection uses them. `{ write: true }` adds create and rename, never delete. `false` hides them. |
97
99
  | `userCollection` | `config.admin.user`, then `users` | Auth collection whose users the keys act as. |
98
100
  | `apiKeys.slug` | `mcpx-api-keys` | Slug of the key collection. |
99
101
  | `apiKeys.setupGuide` | `true` | Add the "Connect a client" button to saved keys. |
@@ -171,6 +173,8 @@ To restrict that, use `apiKeys.overrideCollection` as shown in
171
173
  the REST or GraphQL API.
172
174
  - A key acts as its user, and Payload access control applies to what its tools read and write.
173
175
  - Relations are populated only into collections the key can read.
176
+ - Readable folders expose every folder name and the `folderType` options, which are the slugs of
177
+ the collections that use folders. They do not expose the documents in a folder.
174
178
  - The read tools leave out fields with `admin.hidden`. `findDocuments` refuses a `where` or `sort`
175
179
  that names one, or that goes through a relation into a collection the key cannot read.
176
180
  - After a write, the tools re-read the document with full access to report publish blockers.
@@ -1,9 +1,9 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
- import { grantContext, grantKvSlug, rowHandle } from "../grants/grant.mjs";
2
+ import { leaseContext, leaseHandle, leaseKvSlug } from "../leases/lease.mjs";
3
3
  import { respond } from "./http.mjs";
4
4
  import { CONFIRMATIONS_PATH, DECISIONS } from "../api-keys/confirmation-view.mjs";
5
5
  import { relationId } from "../auth/resolve.mjs";
6
- import { decideConfirmations, issueConfirmation, listConfirmations } from "../grants/confirmation.mjs";
6
+ import { decideConfirmations, issueConfirmation, listConfirmations } from "../leases/confirmation.mjs";
7
7
  import { publicOrigin } from "../request.mjs";
8
8
  import { jsonResult } from "../result.mjs";
9
9
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
@@ -25,7 +25,7 @@ const keyViewUrl = (req, options, apiKeyId) => {
25
25
  confirmed: false,
26
26
  request: async (args) => {
27
27
  const { req } = scope;
28
- const { slug, apiKeyId } = grantContext(req);
28
+ const { slug, apiKeyId } = leaseContext(req);
29
29
  const view = keyViewUrl(req, options, apiKeyId);
30
30
  const { id, exp } = await issueConfirmation(req.payload, slug, {
31
31
  apiKeyId,
@@ -42,7 +42,7 @@ const keyViewUrl = (req, options, apiKeyId) => {
42
42
  }
43
43
  });
44
44
  const authorize = async (req, options, keyId) => {
45
- const slug = grantKvSlug(req.payload.config);
45
+ const slug = leaseKvSlug(req.payload.config);
46
46
  if (slug === void 0) return respond(404, { error: "Confirmations are not available." });
47
47
  if (req.user?.collection !== options.userCollection) return respond(401, { error: "Sign in as the user of this key." });
48
48
  const key = typeof keyId === "string" || typeof keyId === "number" ? await req.payload.findByID({
@@ -88,7 +88,7 @@ const viewOf = async (req, entry, highlight) => {
88
88
  if (allowed instanceof Response) return allowed;
89
89
  const { payload } = req;
90
90
  const highlighted = req.searchParams.get("confirmation");
91
- const highlight = highlighted === null ? void 0 : rowHandle(payload, highlighted);
91
+ const highlight = highlighted === null ? void 0 : leaseHandle(payload, highlighted);
92
92
  const entries = (await listConfirmations(payload, allowed.slug, allowed.apiKeyId)).filter((entry) => entry.confirmation.state === "pending");
93
93
  const confirmations = [];
94
94
  for (const entry of entries) confirmations.push(await viewOf(req, entry, highlight));
@@ -1,5 +1,5 @@
1
1
  import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
2
- import { grantsAvailable, readCapped } from "./http.mjs";
2
+ import { leasesAvailable, readCapped } from "./http.mjs";
3
3
  import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
4
4
  import { jsonRpcError } from "./errors.mjs";
5
5
  import { createMcpServer } from "./server.mjs";
@@ -22,7 +22,7 @@ const BATCH_MESSAGES_LIMIT = 10;
22
22
  defaultLocale: localization.defaultLocale
23
23
  } : null,
24
24
  limits: options.limits,
25
- uploads: grantsAvailable(req, options) !== void 0,
25
+ uploads: leasesAvailable(req, options) !== void 0,
26
26
  diagnostics: options.diagnostics ? options.serverInfo : null,
27
27
  exposure: {
28
28
  collections: options.collections,
@@ -1,4 +1,4 @@
1
- import { grantKvSlug } from "../grants/grant.mjs";
1
+ import { leaseKvSlug } from "../leases/lease.mjs";
2
2
  //#region src/endpoint/http.ts
3
3
  const respond = (status, body) => Response.json(body, { status });
4
4
  /**
@@ -19,9 +19,9 @@ const respond = (status, body) => Response.json(body, { status });
19
19
  return Buffer.concat(chunks);
20
20
  };
21
21
  /**
22
- * The grant KV slug where uploads, downloads and confirmations are possible:
22
+ * The lease KV slug where uploads, downloads and confirmations are possible:
23
23
  * the KV is database-backed and no custom `auth.resolve` is set, since the
24
- * grant endpoints cannot replay what such a resolver authenticated by.
25
- */ const grantsAvailable = (req, options) => options.auth?.resolve === void 0 ? grantKvSlug(req.payload.config) : void 0;
24
+ * lease endpoints cannot replay what such a resolver authenticated by.
25
+ */ const leasesAvailable = (req, options) => options.auth?.resolve === void 0 ? leaseKvSlug(req.payload.config) : void 0;
26
26
  //#endregion
27
- export { grantsAvailable, readCapped, respond };
27
+ export { leasesAvailable, readCapped, respond };
@@ -1,8 +1,8 @@
1
1
  import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
- import { claimGrant } from "../grants/grant.mjs";
3
- import { grantsAvailable, readCapped, respond } from "./http.mjs";
2
+ import { leasesAvailable, readCapped, respond } from "./http.mjs";
4
3
  import { resolveApiKeyById } from "../auth/resolve.mjs";
5
4
  import { parseResult } from "../result.mjs";
5
+ import { claimGrant } from "../leases/grant.mjs";
6
6
  import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
7
7
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
8
8
  import { runTool } from "./server.mjs";
@@ -23,7 +23,7 @@ const NO_SESSION = {
23
23
  sendRequest: () => Promise.reject(/* @__PURE__ */ new Error("No MCP session."))
24
24
  };
25
25
  const claimAs = async (req, options, kind) => {
26
- const slug = grantsAvailable(req, options);
26
+ const slug = leasesAvailable(req, options);
27
27
  const grantId = req.headers.get("x-mcpx-grant");
28
28
  if (slug === void 0 || grantId === null) return;
29
29
  const grant = await claimGrant(req.payload, slug, grantId);
@@ -1,8 +1,7 @@
1
- import { NO_REQ, ROW_ID, claimRow, expOf, issueRow, rowHandle, rowPrefixes, rowsWith } from "./grant.mjs";
2
- //#region src/grants/confirmation.ts
1
+ import { LEASE_ID, NO_REQ, claimLease, issueLease, leaseHandle, leasePrefixes, leasesWith, unexpired } from "./lease.mjs";
2
+ //#region src/leases/confirmation.ts
3
3
  const CONFIRMATION_TTL_MS = 9e5;
4
4
  const HANDLE = /^[\da-f]{64}$/;
5
- const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : void 0;
6
5
  /**
7
6
  * Stores a pending call and returns its id. A key may hold at most
8
7
  * {@link CONFIRMATIONS_PER_KEY} calls.
@@ -11,8 +10,8 @@ const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : v
11
10
  ...call,
12
11
  state: "pending"
13
12
  };
14
- return await issueRow(payload, slug, {
15
- ...rowPrefixes("confirmation", call.apiKeyId),
13
+ return await issueLease(payload, slug, {
14
+ ...leasePrefixes("confirmation", call.apiKeyId),
16
15
  cap: 50,
17
16
  capMessage: `This key already has ${String(50)} calls waiting for approval. Run or abandon them first; each expires 15 minutes after it was requested.`,
18
17
  ttlMs: CONFIRMATION_TTL_MS,
@@ -23,22 +22,22 @@ const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : v
23
22
  * The unexpired call of `apiKeyId` with `id`, without claiming it. A call of
24
23
  * another key is `undefined`, as is a malformed id, before any database read.
25
24
  */ const readConfirmation = async (payload, slug, apiKeyId, id) => {
26
- if (!ROW_ID.test(id)) return;
27
- const key = `${rowPrefixes("confirmation", apiKeyId).prefix}${rowHandle(payload, id)}`;
28
- const rows = await rowsWith(payload, slug, [key]);
29
- return asConfirmation(rows.find((row) => row.key === key), Date.now());
25
+ if (!LEASE_ID.test(id)) return;
26
+ const key = `${leasePrefixes("confirmation", apiKeyId).prefix}${leaseHandle(payload, id)}`;
27
+ const rows = await leasesWith(payload, slug, [key]);
28
+ return unexpired(rows.find((row) => row.key === key), Date.now());
30
29
  };
31
30
  /**
32
31
  * Marks the call used and removes it. Of two concurrent claims exactly one
33
32
  * returns `true`.
34
- */ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) => await claimRow(payload, slug, rowPrefixes("confirmation", apiKeyId), rowHandle(payload, id), exp);
33
+ */ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) => await claimLease(payload, slug, leasePrefixes("confirmation", apiKeyId), leaseHandle(payload, id), exp);
35
34
  /**
36
35
  * Every unexpired call of `apiKeyId` with its handle, oldest first.
37
36
  */ const listConfirmations = async (payload, slug, apiKeyId) => {
38
- const { prefix } = rowPrefixes("confirmation", apiKeyId);
37
+ const { prefix } = leasePrefixes("confirmation", apiKeyId);
39
38
  const now = Date.now();
40
- return (await rowsWith(payload, slug, [prefix])).flatMap((row) => {
41
- const confirmation = asConfirmation(row, now);
39
+ return (await leasesWith(payload, slug, [prefix])).flatMap((row) => {
40
+ const confirmation = unexpired(row, now);
42
41
  return confirmation ? [{
43
42
  handle: row.key.slice(prefix.length),
44
43
  confirmation
@@ -50,13 +49,13 @@ const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : v
50
49
  * returns the handles it changed. A call that was decided, run or expired is
51
50
  * left alone and not returned.
52
51
  */ const decideConfirmations = async (payload, slug, apiKeyId, handles, state) => {
53
- const { prefix } = rowPrefixes("confirmation", apiKeyId);
52
+ const { prefix } = leasePrefixes("confirmation", apiKeyId);
54
53
  const keys = new Set(handles.filter((handle) => HANDLE.test(handle)).map((handle) => `${prefix}${handle}`));
55
54
  if (keys.size === 0) return [];
56
55
  const now = Date.now();
57
56
  const decided = [];
58
- for (const row of await rowsWith(payload, slug, [prefix])) {
59
- const confirmation = asConfirmation(row, now);
57
+ for (const row of await leasesWith(payload, slug, [prefix])) {
58
+ const confirmation = unexpired(row, now);
60
59
  if (!keys.has(row.key) || confirmation?.state !== "pending") continue;
61
60
  const updated = await payload.db.updateMany({
62
61
  collection: slug,
@@ -0,0 +1,29 @@
1
+ import { LEASE_ID, claimLease, issueLease, leaseHandle, leasePrefixes, leasesMatching, unexpired } from "./lease.mjs";
2
+ //#region src/leases/grant.ts
3
+ const GRANT_TTL_MS = 3e5;
4
+ const GRANTS_PER_KEY = 10;
5
+ /**
6
+ * Stores a grant and returns its id. A key may hold at most
7
+ * {@link GRANTS_PER_KEY} grants.
8
+ */ const issueGrant = async (payload, slug, grant) => await issueLease(payload, slug, {
9
+ ...leasePrefixes("grant", grant.apiKeyId),
10
+ cap: GRANTS_PER_KEY,
11
+ 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.`,
12
+ ttlMs: GRANT_TTL_MS,
13
+ data: grant
14
+ });
15
+ /**
16
+ * Loads the grant for `grantId` and marks it used, or returns `undefined` for
17
+ * a malformed, unknown, expired or used one. A malformed id is refused before
18
+ * any database read.
19
+ */ const claimGrant = async (payload, slug, grantId) => {
20
+ if (!LEASE_ID.test(grantId)) return;
21
+ const handle = leaseHandle(payload, grantId);
22
+ const row = (await leasesMatching(payload, slug, [handle])).find((candidate) => candidate.key.startsWith("mcpx-grant:") && candidate.key.endsWith(`:${handle}`));
23
+ const grant = unexpired(row, Date.now());
24
+ if (!row || !grant) return;
25
+ const prefixes = leasePrefixes("grant", grant.apiKeyId);
26
+ return row.key === `${prefixes.prefix}${handle}` && await claimLease(payload, slug, prefixes, handle, grant.exp) ? grant : void 0;
27
+ };
28
+ //#endregion
29
+ export { claimGrant, issueGrant };
@@ -1,33 +1,31 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
2
  import { generateApiKey, hashApiKey } from "../api-keys/key.mjs";
3
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}$/;
4
+ //#region src/leases/lease.ts
5
+ const LEASE_ID = /^[\w-]{43}$/;
8
6
  const NO_REQ = {};
9
7
  /**
10
- * The key prefixes of a kind of row: waiting rows and the markers of used
8
+ * The key prefixes of a kind of lease: waiting leases and the markers of used
11
9
  * ones.
12
- */ const rowPrefixes = (kind, apiKeyId) => ({
10
+ */ const leasePrefixes = (kind, apiKeyId) => ({
13
11
  prefix: `mcpx-${kind}:${String(apiKeyId)}:`,
14
12
  usedPrefix: `mcpx-${kind}-used:${String(apiKeyId)}:`
15
13
  });
16
14
  /**
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);
15
+ * What a lease is addressed by: the HMAC of its id, which the row key ends in.
16
+ */ const leaseHandle = (payload, id) => hashApiKey(payload.secret, id);
19
17
  const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ? data["exp"] : 0;
20
18
  /**
21
19
  * The KV collection slug, when Payload's database KV adapter backs it with a
22
20
  * unique `key`, which the single-use claim relies on. `undefined` otherwise.
23
- */ const grantKvSlug = (config) => {
21
+ */ const leaseKvSlug = (config) => {
24
22
  const kv = config.kv;
25
23
  const key = kv?.kvCollection?.fields.find((field) => "name" in field && field.name === "key");
26
24
  return key && "unique" in key && key.unique ? kv?.kvCollection?.slug : void 0;
27
25
  };
28
26
  /**
29
27
  * KV rows whose key contains one of `fragments`.
30
- */ const findRows = async (payload, slug, fragments) => {
28
+ */ const leasesMatching = async (payload, slug, fragments) => {
31
29
  const { docs } = await payload.db.find({
32
30
  collection: slug,
33
31
  where: { or: fragments.map((fragment) => ({ key: { like: fragment } })) },
@@ -40,15 +38,15 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
40
38
  /**
41
39
  * KV rows whose key starts with one of `prefixes`. `like` matches a
42
40
  * 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)));
41
+ */ const leasesWith = async (payload, slug, prefixes) => (await leasesMatching(payload, slug, prefixes)).filter((row) => prefixes.some((prefix) => row.key.startsWith(prefix)));
44
42
  /**
45
43
  * Stores `data` under `prefix` and the HMAC of a fresh id, and returns the id,
46
44
  * the only copy of which goes to the client. Expired rows under `prefix` and
47
45
  * `usedPrefix` are purged first, and at most `cap` unexpired rows may wait
48
46
  * under `prefix`; past that, `capMessage` is thrown with 429.
49
- */ const issueRow = async (payload, slug, args) => {
47
+ */ const issueLease = async (payload, slug, args) => {
50
48
  const now = Date.now();
51
- const rows = await rowsWith(payload, slug, [args.prefix, args.usedPrefix]);
49
+ const rows = await leasesWith(payload, slug, [args.prefix, args.usedPrefix]);
52
50
  const expired = rows.filter((row) => expOf(row.data) <= now);
53
51
  if (expired.length > 0) await payload.db.deleteMany({
54
52
  collection: slug,
@@ -61,7 +59,7 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
61
59
  await payload.db.create({
62
60
  collection: slug,
63
61
  data: {
64
- key: `${args.prefix}${rowHandle(payload, id)}`,
62
+ key: `${args.prefix}${leaseHandle(payload, id)}`,
65
63
  data: {
66
64
  ...args.data,
67
65
  exp
@@ -77,7 +75,7 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
77
75
  /**
78
76
  * Marks the row under `handle` used and removes it. The marker is a
79
77
  * unique-key insert, so of two concurrent claims exactly one returns `true`.
80
- */ const claimRow = async (payload, slug, prefixes, handle, exp) => {
78
+ */ const claimLease = async (payload, slug, prefixes, handle, exp) => {
81
79
  try {
82
80
  await payload.db.create({
83
81
  collection: slug,
@@ -99,39 +97,19 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
99
97
  return true;
100
98
  };
101
99
  /**
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);
100
+ * The KV slug and key id of the request, which a call that stores a lease
101
+ * needs. Throws where the endpoint would not offer one.
102
+ */ const leaseContext = (req) => {
103
+ const slug = leaseKvSlug(req.payload.config);
106
104
  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.");
105
+ if (slug === void 0 || apiKeyId === void 0) throw new Error("A lease needs a database KV and a key.");
108
106
  return {
109
107
  slug,
110
108
  apiKeyId
111
109
  };
112
110
  };
113
111
  /**
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
- };
112
+ * The data of `row` when it has not expired at `now`, otherwise `undefined`.
113
+ */ const unexpired = (row, now) => row && expOf(row.data) > now ? row.data : void 0;
136
114
  //#endregion
137
- export { NO_REQ, ROW_ID, claimGrant, claimRow, expOf, grantContext, grantKvSlug, issueGrant, issueRow, rowHandle, rowPrefixes, rowsWith };
115
+ export { LEASE_ID, NO_REQ, claimLease, issueLease, leaseContext, leaseHandle, leaseKvSlug, leasePrefixes, leasesMatching, leasesWith, unexpired };
package/dist/options.mjs CHANGED
@@ -5,6 +5,7 @@ import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
5
5
  //#region src/options.ts
6
6
  const DEFAULT_API_KEYS_SLUG = "mcpx-api-keys";
7
7
  const DEFAULT_ENDPOINT_PATH = "/mcpx";
8
+ const DEFAULT_FOLDERS_SLUG = "payload-folders";
8
9
  const DEFAULT_MAX_LIMIT = 25;
9
10
  const DEFAULT_MAX_DEPTH = 1;
10
11
  const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
@@ -83,12 +84,13 @@ const normalizeCapabilities = (kind, config, raw) => {
83
84
  };
84
85
  };
85
86
  const bySlug = (configs) => new Map(configs.map((config) => [config.slug, config]));
86
- const normalizeEntities = (kind, configs, entries, apiKeysSlug) => {
87
+ const foldersSlugOf = ({ folders }) => (folders === false ? void 0 : folders?.slug) ?? DEFAULT_FOLDERS_SLUG;
88
+ const normalizeEntities = (kind, configs, entries, apiKeysSlug, foldersSlug) => {
87
89
  const fieldNames = /* @__PURE__ */ new Set();
88
90
  return Object.entries(entries).flatMap(([slug, raw]) => {
89
91
  if (raw === void 0) return [];
90
92
  const config = configs.get(slug);
91
- if (!config) return fail(`Exposed ${kind.toLowerCase()} "${slug}" does not exist.`);
93
+ if (!config) return fail(slug === foldersSlug ? `Collection "${slug}" is Payload's folder collection. Use the folders option instead.` : slug.startsWith("payload-") ? `${kind} "${slug}" is internal to Payload and cannot be exposed.` : `Exposed ${kind.toLowerCase()} "${slug}" does not exist.`);
92
94
  assertExposable(kind, config, apiKeysSlug);
93
95
  const normalized = {
94
96
  slug,
@@ -105,6 +107,40 @@ const normalizeEntities = (kind, configs, entries, apiKeysSlug) => {
105
107
  return [normalized];
106
108
  });
107
109
  };
110
+ const normalizeFolders = (config, collections, raw, slug) => {
111
+ const usesFolders = (config.collections ?? []).some((collection) => Boolean(collection.folders) && collections.some((entity) => entity.slug === collection.slug));
112
+ if (raw === false) return [];
113
+ if (config.folders === false || !usesFolders) {
114
+ if (raw !== void 0) fail("folders is set, but no exposed collection uses folders. Remove the option.");
115
+ return [];
116
+ }
117
+ if (raw !== void 0 && !isPlainObject(raw)) fail(`folders has ${JSON.stringify(raw)}. Use false or { write }.`);
118
+ const settings = isPlainObject(raw) ? raw : {};
119
+ const unknownKey = Object.keys(settings).find((key) => key !== "write");
120
+ if (unknownKey !== void 0) fail(`folders has the unknown option "${unknownKey}". The only option is write.`);
121
+ const fieldName = toCamelCase(slug);
122
+ if (collections.some((entity) => entity.fieldName === fieldName)) fail(`folders maps to capability field "${fieldName}", which an exposed collection already uses.`);
123
+ const write = settings["write"] ?? false;
124
+ if (typeof write !== "boolean") fail(`folders.write has ${JSON.stringify(write)}. Use true or false.`);
125
+ return [{
126
+ slug,
127
+ ...normalizeCapabilities("Collection", {
128
+ slug,
129
+ fields: []
130
+ }, { write }),
131
+ isUpload: false,
132
+ fieldName
133
+ }];
134
+ };
135
+ /**
136
+ * Checks the sanitized folder collection against what {@link normalizeFolders}
137
+ * assumed. `folders.collectionOverrides` can change it after this plugin ran.
138
+ */ const assertFolderCollection = (config, options) => {
139
+ const { folders } = config;
140
+ if (!folders || !options.collections.some((entity) => entity.slug === folders.slug)) return;
141
+ const collection = config.collections.find((candidate) => candidate.slug === folders.slug);
142
+ if (collection?.auth || collection?.upload || collection?.versions) fail(`Folder collection "${folders.slug}" has auth, upload or versions from folders.collectionOverrides, which mcpx does not support. Set folders: false.`);
143
+ };
108
144
  const assertUserCollection = (collections, slug) => {
109
145
  const collection = collections.get(slug);
110
146
  if (!collection) fail(`User collection "${slug}" does not exist.`);
@@ -136,11 +172,13 @@ const normalizeLimits = (limits) => {
136
172
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
137
173
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
138
174
  const collectionConfigs = bySlug(config.collections ?? []);
175
+ const foldersSlug = foldersSlugOf(config);
139
176
  if (collectionConfigs.has(apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
140
177
  assertUserCollection(collectionConfigs, userCollection);
141
178
  const tools = options.tools ?? [];
142
179
  assertTools(tools, reservedToolNames);
143
- const collections = normalizeEntities("Collection", collectionConfigs, options.collections, apiKeysSlug);
180
+ const exposed = normalizeEntities("Collection", collectionConfigs, options.collections, apiKeysSlug, foldersSlug);
181
+ const collections = [...exposed, ...normalizeFolders(config, exposed, options.folders, foldersSlug)];
144
182
  const confirmations = collections.some((entity) => entity.delete);
145
183
  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.");
146
184
  return {
@@ -162,4 +200,4 @@ const normalizeLimits = (limits) => {
162
200
  };
163
201
  };
164
202
  //#endregion
165
- export { normalizeOptions, toCamelCase };
203
+ export { assertFolderCollection, normalizeOptions, toCamelCase };
package/dist/plugin.d.mts CHANGED
@@ -3,7 +3,8 @@ import { McpxPluginOptions } from "./types.mjs";
3
3
  /**
4
4
  * Mounts the MCP, upload and download endpoints, and the confirmation
5
5
  * endpoints where a collection exposes `delete`. Adds the API key collection
6
- * and installs the draft guard on every collection and global.
6
+ * and installs the draft guard on every collection and global. Checks the
7
+ * folder collection once Payload has built it.
7
8
  */
8
9
  export declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
9
10
  //#endregion
package/dist/plugin.mjs CHANGED
@@ -3,14 +3,15 @@ import { BUILTIN_TOOLS } from "./tools/builtin.mjs";
3
3
  import { confirmationEndpoints } from "./endpoint/confirmations.mjs";
4
4
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
5
5
  import { createDownloadHandler, createUploadHandler } from "./endpoint/upload.mjs";
6
- import { normalizeOptions } from "./options.mjs";
6
+ import { assertFolderCollection, normalizeOptions } from "./options.mjs";
7
7
  import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
8
8
  import { definePlugin } from "payload";
9
9
  //#region src/plugin.ts
10
10
  /**
11
11
  * Mounts the MCP, upload and download endpoints, and the confirmation
12
12
  * endpoints where a collection exposes `delete`. Adds the API key collection
13
- * and installs the draft guard on every collection and global.
13
+ * and installs the draft guard on every collection and global. Checks the
14
+ * folder collection once Payload has built it.
14
15
  */ const mcpxPlugin = definePlugin({
15
16
  slug: "@abinnovision/payloadcms-mcpx",
16
17
  order: 100,
@@ -22,6 +23,10 @@ import { definePlugin } from "payload";
22
23
  ...config,
23
24
  collections: installDraftGuards([...config.collections ?? [], apiKeysCollection]),
24
25
  globals: installGlobalDraftGuards(config.globals ?? []),
26
+ onInit: async (payload) => {
27
+ assertFolderCollection(payload.config, normalized);
28
+ await config.onInit?.(payload);
29
+ },
25
30
  endpoints: [
26
31
  ...config.endpoints ?? [],
27
32
  {
@@ -1,6 +1,6 @@
1
1
  import { defineMcpxTool, isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
- import { grantContext } from "../grants/grant.mjs";
3
- import { claimConfirmation, readConfirmation } from "../grants/confirmation.mjs";
2
+ import { leaseContext } from "../leases/lease.mjs";
3
+ import { claimConfirmation, readConfirmation } from "../leases/confirmation.mjs";
4
4
  import { jsonResult, parseResult } from "../result.mjs";
5
5
  import { withRequestLocale } from "./document.mjs";
6
6
  import { z } from "zod";
@@ -86,7 +86,7 @@ const claimApproved = async (req, slug, apiKeyId, id) => {
86
86
  inputSchema: { ids: z.array(z.string().max(64)).min(1).max(50).describe("\"id\" of each \"confirmation\".") },
87
87
  handler: async ({ args, scope, extra }) => {
88
88
  const { req } = scope;
89
- const { slug, apiKeyId } = grantContext(req);
89
+ const { slug, apiKeyId } = leaseContext(req);
90
90
  const outcomes = [];
91
91
  for (const id of args.ids) {
92
92
  const confirmation = await claimApproved(req, slug, apiKeyId, id);
package/dist/types.d.mts CHANGED
@@ -274,6 +274,15 @@ export type McpxPluginOptions = {
274
274
  * Allow-list of globals. `true` is shorthand for `{}`.
275
275
  */
276
276
  globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
277
+ /**
278
+ * Payload's folder collection, exposed read-only by default where an exposed
279
+ * collection has `folders: true`. `{ write: true }` also lets keys create and
280
+ * rename folders; folders are never deleted. `false` hides them. An object
281
+ * is refused where no exposed collection uses folders.
282
+ */
283
+ folders?: false | {
284
+ write?: boolean;
285
+ };
277
286
  /**
278
287
  * Collection the keys act as. Default `config.admin.user`, then `users`.
279
288
  */
@@ -1,6 +1,7 @@
1
- import { grantContext, issueGrant } from "../grants/grant.mjs";
1
+ import { leaseContext } from "../leases/lease.mjs";
2
2
  import { publicOrigin } from "../request.mjs";
3
3
  import { errorResult, jsonResult } from "../result.mjs";
4
+ import { issueGrant } from "../leases/grant.mjs";
4
5
  import { SchemaError } from "../schema/errors.mjs";
5
6
  import { resolveDataPointer } from "../schema/pointer.mjs";
6
7
  import "../schema/index.mjs";
@@ -126,7 +127,7 @@ const handoffUrl = (req, path) => {
126
127
  */ const uploadHandoff = async (scope, call) => {
127
128
  const { req } = scope;
128
129
  const { payload } = req;
129
- const { slug, apiKeyId } = grantContext(req);
130
+ const { slug, apiKeyId } = leaseContext(req);
130
131
  const url = handoffUrl(req, "upload");
131
132
  const { id, exp } = await issueGrant(payload, slug, {
132
133
  kind: "upload",
@@ -158,7 +159,7 @@ const handoffUrl = (req, path) => {
158
159
  * GET it.
159
160
  */ const downloadHandoff = async (scope, target) => {
160
161
  const { req } = scope;
161
- const { slug, apiKeyId } = grantContext(req);
162
+ const { slug, apiKeyId } = leaseContext(req);
162
163
  const url = handoffUrl(req, "file");
163
164
  const { id, exp } = await issueGrant(req.payload, slug, {
164
165
  kind: "download",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "2.0.0",
4
+ "version": "2.1.0-beta.2",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",