@abinnovision/payloadcms-mcpx 2.1.0-beta.1 → 2.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.0](https://github.com/abinnovision/payloadcms-commons/compare/payloadcms-mcpx-v2.0.0...payloadcms-mcpx-v2.1.0) (2026-10-09)
4
+
5
+
6
+ ### Features
7
+
8
+ * **mcpx:** expose payload folders read-only by default ([#129](https://github.com/abinnovision/payloadcms-commons/issues/129)) ([1b2dd55](https://github.com/abinnovision/payloadcms-commons/commit/1b2dd55305fb84991f9a585006a3a33687bb22a7))
9
+ * **mcpx:** rename grants to leases and share the expiry check ([#127](https://github.com/abinnovision/payloadcms-commons/issues/127)) ([7170bd5](https://github.com/abinnovision/payloadcms-commons/commit/7170bd5d592226c6bb7f356fb17ec1b19a59e608))
10
+
3
11
  ## [2.0.0](https://github.com/abinnovision/payloadcms-commons/compare/payloadcms-mcpx-v1.0.0...payloadcms-mcpx-v2.0.0) (2026-10-08)
4
12
 
5
13
 
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.
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 {
@@ -156,10 +194,10 @@ const normalizeLimits = (limits) => {
156
194
  auth: options.auth,
157
195
  serverInfo: {
158
196
  name: options.serverInfo?.name ?? "payloadcms-mcpx",
159
- version: options.serverInfo?.version ?? "2.0.0"
197
+ version: options.serverInfo?.version ?? "2.1.0"
160
198
  },
161
199
  diagnostics: options.diagnostics ?? true
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
  {
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
  */
package/dist/version.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  //#region src/version.ts
2
2
  /**
3
3
  * Package version injected at build time; sources under Vitest report "dev".
4
- */ const MCPX_VERSION = "2.0.0";
4
+ */ const MCPX_VERSION = "2.1.0";
5
5
  const MCPX_REPOSITORY_URL = "https://github.com/abinnovision/payloadcms-commons/tree/main/packages/mcpx";
6
6
  const MCPX_ISSUES_URL = "https://github.com/abinnovision/payloadcms-commons/issues";
7
7
  //#endregion
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.1.0-beta.1",
4
+ "version": "2.1.0",
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",