@abinnovision/payloadcms-mcpx 1.0.0-beta.3 → 1.0.0-beta.5

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
@@ -44,6 +44,9 @@ export default buildConfig({
44
44
  posts: { read: true, write: true },
45
45
  tags: true, // shorthand for { read: true }
46
46
  },
47
+ globals: {
48
+ "site-settings": { read: true, write: true },
49
+ },
47
50
  limits: { maxLimit: 25, maxDepth: 1 },
48
51
  }),
49
52
  ],
@@ -56,8 +59,8 @@ The plugin adds:
56
59
  JSON responses; `GET`/`DELETE` answer 405),
57
60
  - an `mcpx-api-keys` collection (admin group "MCP") holding the keys and their
58
61
  capability checkboxes,
59
- - a draft guard on every collection, so any write carrying the MCP request
60
- marker lands as a draft, including writes made by custom tools.
62
+ - a draft guard on every collection and global, so any write carrying the MCP
63
+ request marker lands as a draft, including writes made by custom tools.
61
64
 
62
65
  ## API keys
63
66
 
@@ -118,35 +121,39 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
118
121
 
119
122
  ## Tools
120
123
 
121
- The surface is fixed at seven tools plus your custom ones. `tools/list`
122
- reflects the key: write tools disappear for read-only keys, and every
123
- `collection` enum contains only the slugs the key may touch.
124
+ The surface is fixed at seven tools plus your custom ones; exposing a global
125
+ adds an argument, never a tool. `tools/list` reflects the key: write tools
126
+ disappear for read-only keys, and every `collection` and `global` enum contains
127
+ only the slugs the key may touch.
124
128
 
125
129
  | Tool | Purpose | Key arguments |
126
130
  | ------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
127
131
  | `listCapabilities` | What this key may do; call first to orient. | none |
128
- | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection`, `paths?`, `expand?` |
132
+ | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection` \| `global`, `paths?`, `expand?` |
129
133
  | `findDocuments` | Query documents. | `collection`, `where?`, `sort?`, `limit?`, `page?`, `depth?`, `select?`, `locale?`, `draft?` |
130
- | `getDocument` | Read one document or a subtree of it. | `collection`, `id`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
131
- | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection`, `id`, `locale`, `patches`, `expectedUpdatedAt?` |
134
+ | `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
135
+ | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
132
136
  | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
133
- | `validateDocument` | Publish blockers without writing. | `collection`, `id`, `locale` |
137
+ | `validateDocument` | Publish blockers without writing. | `collection` + `id` \| `global`, `locale` |
134
138
 
135
139
  Rules the tools enforce and explain in their own descriptions:
136
140
 
137
- - `describeSchema` paths are dotted and stop at blocks fields, which list the
138
- block slugs they accept; every node carries `next`, the ready-to-use paths
139
- for those blocks (`layout.sections.sectionWrapper`), so pass an entry of
140
- `next` as a `paths` element to descend. A block is described as it exists at
141
- that position.
141
+ - `describeSchema` paths stop at blocks fields, which list the block slugs they
142
+ accept; every node carries `next`, the ready-to-use paths for those blocks
143
+ (`/layout/sections/sectionWrapper`), so pass an entry of `next` as a `paths`
144
+ element to descend. A block is described as it exists at that position.
142
145
  - Field and collection `admin.description` values are included in
143
146
  `describeSchema` and `listCapabilities`, so intent written for the admin
144
147
  panel reaches the client. Strings and locale-keyed records pass through;
145
148
  functions and components are dropped.
146
149
  - Builtin tools reject unknown arguments by name instead of silently ignoring
147
150
  them.
148
- - A schema path becomes a patch pointer by replacing `.` with `/`, adding a
149
- leading `/`, and replacing each `[]` with a 0-based index.
151
+ - Every path this plugin accepts or reports is a JSON Pointer. A schema path
152
+ and a pointer into a document differ only in what stands in an element
153
+ position: a schema path writes `*` for an array element and names a block by
154
+ its slug, where a pointer carries a 0-based index. So `/items/*/title` is
155
+ written at `/items/0/title`, and `/layout/sections/hero` at
156
+ `/layout/sections/0`.
150
157
  - Adding a block requires `blockType` on the value; append with `/-`.
151
158
  - Clearing is `replace` with `null`; a list is emptied with `[]` and refuses
152
159
  `null`. `remove` is only valid on list elements, because Payload keeps
@@ -158,13 +165,55 @@ Rules the tools enforce and explain in their own descriptions:
158
165
  `deletedAt`) are never listed and never writable; `readOnly` fields are
159
166
  listed but refused on write.
160
167
 
168
+ ## Globals
169
+
170
+ A global is exposed the same way a collection is, and reached through the same
171
+ tools rather than tools of its own:
172
+
173
+ ```ts
174
+ mcpxPlugin({
175
+ collections: { pages: { read: true, write: true } },
176
+ globals: { "site-settings": { read: true, write: true } },
177
+ });
178
+ ```
179
+
180
+ Two rules follow from a global being a singleton, and because JSON Schema cannot
181
+ state either one, both are enforced in the handler and repeated in every
182
+ affected tool description:
183
+
184
+ - Pass exactly **one** of `collection` and `global`.
185
+ - `id` is required with `collection` and must be omitted with `global`.
186
+
187
+ Refusals name the offending argument and the slug, so one failed call teaches
188
+ the rule. `findDocuments` and `createDocument` stay collection-only: there is
189
+ nothing to list and nothing to create when the document always exists. They
190
+ reject a `global` argument by name.
191
+
192
+ Globals get their own `capabilities.globals.<name>` checkbox group, a separate
193
+ namespace from `capabilities.collections.<name>`, so a global may share a
194
+ camelCase name with a collection. Keys issued before a global was exposed have
195
+ no such group, and an absent checkbox reads as `false`, so they stay closed to
196
+ every global until one is ticked.
197
+
198
+ Globals always carry `updatedAt` — Payload appends it and there is no
199
+ `timestamps: false` for globals — so `expectedUpdatedAt` behaves as it does for
200
+ collections. The one exception is a global that has never been saved: it has no
201
+ `updatedAt` to compare against, so the first write must omit
202
+ `expectedUpdatedAt`, and supplying one is refused as a concurrency failure.
203
+
204
+ If `tools/list` omits `global` entirely, no global is exposed to that key; the
205
+ argument only appears once one is. A deployment that uses no globals sees the
206
+ tool schemas exactly as they were.
207
+
161
208
  ## Drafts and publish blockers
162
209
 
163
210
  Draft-only writing is enforced on the Payload operation, not in the tool
164
211
  handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
165
212
  from every write carrying the MCP request marker, so custom tools and anything
166
213
  else writing through the same request are covered too. A `beforeChange` hook
167
- refuses any write that would still not land as a draft.
214
+ refuses any write that would still not land as a draft. Globals expose the same
215
+ `beforeOperation` interception point at the same position in the operation, so
216
+ they are guarded exactly as strongly as collections, exposed or not.
168
217
 
169
218
  Publish blockers are advisory. Payload skips validation on draft saves (unless
170
219
  `versions.drafts.validate` is set), so after every write the plugin re-runs
@@ -175,7 +224,8 @@ validated; field `beforeChange` hooks run again during the check, so they must
175
224
  be pure; and the check runs privileged, so blocker paths and messages may name
176
225
  fields the key's user cannot read (values are never included).
177
226
  Collections with `versions.drafts.validate: true` refuse invalid drafts
178
- outright; those failures come back as `validationErrors`.
227
+ outright; those failures come back as `validationErrors`. Both carry pointers,
228
+ restated from the dotted paths Payload reports internally.
179
229
 
180
230
  Writes also report `notApplied`: pointers whose value Payload kept unchanged,
181
231
  which happens when field-level access denies the update.
@@ -220,6 +270,10 @@ Builtin tools reject them instead.
220
270
  | `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
221
271
  | `collections.<slug>.write` | `false` | Expose `patchDocument`, `createDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
222
272
  | `collections.<slug>.allowLiveWrites` | `false` | Permit writes to a collection without drafts (they land live). |
273
+ | `globals` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
274
+ | `globals.<slug>.read` | `true` | Expose `describeSchema`, `getDocument`. |
275
+ | `globals.<slug>.write` | `false` | Expose `patchDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
276
+ | `globals.<slug>.allowLiveWrites` | `false` | Permit writes to a global without drafts (they land live). |
223
277
  | `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
224
278
  | `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
225
279
  | `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
@@ -243,15 +297,14 @@ key of every user.
243
297
  - The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
244
298
  ignored. Keys cannot authenticate REST or GraphQL.
245
299
  - Every operation runs under the linked user with `overrideAccess: false`.
246
- - Not covered in v1: `delete` (no tool exists and none is generated), globals,
247
- uploads. Custom tools are trusted code and can do what the linked user may.
300
+ - Not covered in v1: `delete` (no tool exists and none is generated), uploads.
301
+ Custom tools are trusted code and can do what the linked user may.
248
302
 
249
303
  ## Non-goals of v1 / roadmap
250
304
 
251
- Globals, deletes, uploads, markdown authoring for rich text, row addressing by
252
- id instead of index, cross-locale publish blockers, pagination of
253
- `describeSchema` with `expand`, and a handler-level timeout are all deliberate
254
- omissions for now.
305
+ Deletes, uploads, markdown authoring for rich text, row addressing by id
306
+ instead of index, cross-locale publish blockers, pagination of `describeSchema`
307
+ with `expand`, and a handler-level timeout are all deliberate omissions for now.
255
308
 
256
309
  ## License
257
310
 
@@ -56,9 +56,10 @@ const checkbox = (name, description) => ({
56
56
  }
57
57
  ];
58
58
  /**
59
- * One checkbox per exposed operation, grouped per collection and per custom
60
- * tool. Only operations the plugin config exposes get a checkbox, so a key can
61
- * never enable more than the config allows. Everything defaults to off.
59
+ * One checkbox per exposed operation, grouped per collection, per global and
60
+ * per custom tool. Only operations the plugin config exposes get a checkbox, so
61
+ * a key can never enable more than the config allows. Everything defaults to
62
+ * off, which is why a key issued before a capability existed stays closed to it.
62
63
  */ const createCapabilityFields = (options) => {
63
64
  const collectionGroups = options.collections.map((collection) => ({
64
65
  name: collection.fieldName,
@@ -66,16 +67,30 @@ const checkbox = (name, description) => ({
66
67
  label: collection.slug,
67
68
  fields: [...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [], ...collection.write ? [checkbox("write", "Create, patch and validate drafts.")] : []]
68
69
  }));
69
- const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
70
- const groups = [...collectionGroups.length > 0 ? [{
71
- name: "collections",
72
- type: "group",
73
- fields: collectionGroups
74
- }] : [], ...toolCheckboxes.length > 0 ? [{
75
- name: "tools",
70
+ const globalGroups = options.globals.map((global) => ({
71
+ name: global.fieldName,
76
72
  type: "group",
77
- fields: toolCheckboxes
78
- }] : []];
73
+ label: global.slug,
74
+ fields: [...global.read ? [checkbox("read", "Describe and read this global.")] : [], ...global.write ? [checkbox("write", "Patch and validate this global's draft.")] : []]
75
+ }));
76
+ const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
77
+ const groups = [
78
+ ...collectionGroups.length > 0 ? [{
79
+ name: "collections",
80
+ type: "group",
81
+ fields: collectionGroups
82
+ }] : [],
83
+ ...globalGroups.length > 0 ? [{
84
+ name: "globals",
85
+ type: "group",
86
+ fields: globalGroups
87
+ }] : [],
88
+ ...toolCheckboxes.length > 0 ? [{
89
+ name: "tools",
90
+ type: "group",
91
+ fields: toolCheckboxes
92
+ }] : []
93
+ ];
79
94
  if (groups.length === 0) return [];
80
95
  return [{
81
96
  name: CAPABILITIES_FIELD,
@@ -8,6 +8,7 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
8
8
  * issued before a capability existed stay closed.
9
9
  */ const resolveCapabilities = (options, keyCapabilities) => {
10
10
  const collectionsGroup = isRecord(keyCapabilities) ? keyCapabilities["collections"] : void 0;
11
+ const globalsGroup = isRecord(keyCapabilities) ? keyCapabilities["globals"] : void 0;
11
12
  const toolsGroup = isRecord(keyCapabilities) ? keyCapabilities["tools"] : void 0;
12
13
  const collections = {};
13
14
  for (const collection of options.collections) {
@@ -17,14 +18,26 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
17
18
  write: collection.write && flag(group, "write")
18
19
  };
19
20
  }
21
+ const globals = {};
22
+ for (const global of options.globals) {
23
+ const group = isRecord(globalsGroup) ? globalsGroup[global.fieldName] : void 0;
24
+ globals[global.slug] = {
25
+ read: global.read && flag(group, "read"),
26
+ write: global.write && flag(group, "write")
27
+ };
28
+ }
20
29
  const tools = {};
21
30
  for (const tool of options.tools) tools[tool.name] = flag(toolsGroup, tool.name);
22
31
  return {
23
32
  collections,
33
+ globals,
24
34
  tools
25
35
  };
26
36
  };
27
- const readableSlugs = (capabilities) => Object.entries(capabilities.collections).filter(([, value]) => value.read).map(([slug]) => slug);
28
- const writableSlugs = (capabilities) => Object.entries(capabilities.collections).filter(([, value]) => value.write).map(([slug]) => slug);
37
+ const pick = (entries, operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
38
+ const readableSlugs = (capabilities) => pick(capabilities.collections, "read");
39
+ const writableSlugs = (capabilities) => pick(capabilities.collections, "write");
40
+ const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read");
41
+ const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
29
42
  //#endregion
30
- export { CAPABILITIES_FIELD, readableSlugs, resolveCapabilities, writableSlugs };
43
+ export { CAPABILITIES_FIELD, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
@@ -1,4 +1,4 @@
1
- import { readableSlugs, resolveCapabilities, writableSlugs } from "../capabilities.mjs";
1
+ import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
2
2
  import { resolveApiKeyAuth } from "../auth/resolve.mjs";
3
3
  import { jsonRpcError } from "./result.mjs";
4
4
  import { createMcpServer } from "./server.mjs";
@@ -12,6 +12,8 @@ const buildScope = (req, options, capabilities) => {
12
12
  capabilities,
13
13
  readable: readableSlugs(capabilities),
14
14
  writable: writableSlugs(capabilities),
15
+ readableGlobals: readableGlobalSlugs(capabilities),
16
+ writableGlobals: writableGlobalSlugs(capabilities),
15
17
  locales: localization ? localization.localeCodes : null,
16
18
  defaultLocale: localization ? localization.defaultLocale : null
17
19
  };
@@ -1,3 +1,4 @@
1
+ import { pointerFromPayloadPath } from "../schema/walk.mjs";
1
2
  import { APIError, ValidationError } from "payload";
2
3
  //#region src/endpoint/result.ts
3
4
  /**
@@ -41,12 +42,17 @@ import { APIError, ValidationError } from "payload";
41
42
  * Maps an exception thrown by a tool to a result the client can read.
42
43
  *
43
44
  * Payload's public errors keep their message and status; a `ValidationError`
44
- * also surfaces its per-field detail. Anything else is logged and reported as
45
- * an internal error so no stack or driver message leaks to the client.
45
+ * also surfaces its per-field detail, with each field's path restated as a
46
+ * JSON Pointer so it reads like every other path this plugin reports. Anything
47
+ * else is logged and reported as an internal error so no stack or driver
48
+ * message leaks to the client.
46
49
  */ const toToolError = (error, logger) => {
47
50
  if (error instanceof ValidationError) return errorResult(error.message, {
48
51
  status: error.status,
49
- validationErrors: error.data.errors
52
+ validationErrors: error.data.errors.map((entry) => ({
53
+ ...entry,
54
+ path: pointerFromPayloadPath(entry.path)
55
+ }))
50
56
  });
51
57
  if (error instanceof APIError && error.isPublic) return errorResult(error.message, { status: error.status });
52
58
  logger.error({
@@ -18,7 +18,7 @@ import { z } from "zod";
18
18
  const server = new McpServer({
19
19
  name: options.serverInfo.name,
20
20
  version: options.serverInfo.version
21
- }, { instructions: "Start with listCapabilities, then describeSchema for the collection you work on. Writes always land as drafts; a human publishes." });
21
+ }, { instructions: "Start with listCapabilities, then describeSchema for the collection or global you work on. Writes always land as drafts; a human publishes." });
22
22
  const guarded = (run) => async () => {
23
23
  try {
24
24
  return await run();
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
1
+ import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
2
2
  import { mcpxPlugin } from "./plugin.mjs";
3
3
  import { isMcpxRequest } from "./write/draft-guard.mjs";
4
4
  import { PublishBlocker } from "./write/publish-blockers.mjs";
5
- export { type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type PublishBlocker, defineMcpxTool, isMcpxRequest, mcpxPlugin };
5
+ export { type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type PublishBlocker, defineMcpxTool, isMcpxRequest, mcpxPlugin };
@@ -0,0 +1,2 @@
1
+ import "./types.mjs";
2
+ import "payload";
package/dist/options.mjs CHANGED
@@ -30,6 +30,19 @@ const assertWritable = (collection, options) => {
30
30
  if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
31
31
  if (!options.hasDrafts && !options.allowLiveWrites) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
32
32
  };
33
+ /**
34
+ * Refuses globals that must never be reachable. Globals cannot be auth or
35
+ * upload entities, so only Payload's own reserved namespace is left to guard.
36
+ */ const assertGlobalExposable = (global) => {
37
+ if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
38
+ };
39
+ /**
40
+ * `GlobalConfig` has no `timestamps` option and `sanitizeGlobal` always appends
41
+ * `createdAt`/`updatedAt`, so the concurrency check the collection path guards
42
+ * for is always available here. Drafts are the only requirement left.
43
+ */ const assertGlobalWritable = (global, options) => {
44
+ if (!options.hasDrafts && !options.allowLiveWrites) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
45
+ };
33
46
  const normalizeCollections = (config, options, apiKeysSlug) => {
34
47
  const collections = config.collections ?? [];
35
48
  const fieldNames = /* @__PURE__ */ new Set();
@@ -54,6 +67,30 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
54
67
  return [normalized];
55
68
  });
56
69
  };
70
+ const normalizeGlobals = (config, options) => {
71
+ const globals = config.globals ?? [];
72
+ const fieldNames = /* @__PURE__ */ new Set();
73
+ return Object.entries(options.globals ?? {}).flatMap(([slug, raw]) => {
74
+ if (raw === void 0) return [];
75
+ const global = globals.find((candidate) => candidate.slug === slug);
76
+ if (!global) return fail(`Exposed global "${slug}" does not exist.`);
77
+ assertGlobalExposable(global);
78
+ const settings = raw === true ? {} : raw;
79
+ const hasDrafts = hasDraftsEnabled(global);
80
+ const normalized = {
81
+ slug,
82
+ read: settings.read ?? true,
83
+ write: settings.write ?? false,
84
+ allowLiveWrites: settings.allowLiveWrites ?? false,
85
+ hasDrafts,
86
+ fieldName: toCamelCase(slug)
87
+ };
88
+ if (normalized.write) assertGlobalWritable(global, normalized);
89
+ if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
90
+ fieldNames.add(normalized.fieldName);
91
+ return [normalized];
92
+ });
93
+ };
57
94
  const assertUserCollection = (config, slug) => {
58
95
  const collection = (config.collections ?? []).find((candidate) => candidate.slug === slug);
59
96
  if (!collection) fail(`User collection "${slug}" does not exist.`);
@@ -91,6 +128,7 @@ const normalizeLimits = (limits) => {
91
128
  assertTools(tools);
92
129
  return {
93
130
  collections: normalizeCollections(config, options, apiKeysSlug),
131
+ globals: normalizeGlobals(config, options),
94
132
  userCollection,
95
133
  apiKeysSlug,
96
134
  endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
@@ -99,7 +137,7 @@ const normalizeLimits = (limits) => {
99
137
  auth: options.auth,
100
138
  serverInfo: {
101
139
  name: options.serverInfo?.name ?? "payloadcms-mcpx",
102
- version: options.serverInfo?.version ?? "1.0.0-beta.3"
140
+ version: options.serverInfo?.version ?? "0.0.0"
103
141
  }
104
142
  };
105
143
  };
package/dist/plugin.d.mts CHANGED
@@ -2,7 +2,7 @@ import { McpxPluginOptions } from "./types.mjs";
2
2
  //#region src/plugin.d.ts
3
3
  /**
4
4
  * Mounts the MCP endpoint, adds the API key collection and installs the
5
- * draft guard on every collection.
5
+ * draft guard on every collection and global.
6
6
  */
7
7
  declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
8
8
  //#endregion
package/dist/plugin.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
3
  import { normalizeOptions } from "./options.mjs";
4
- import { installDraftGuards } from "./write/draft-guard.mjs";
4
+ import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
5
  import { definePlugin } from "payload";
6
6
  //#region src/plugin.ts
7
7
  /**
8
8
  * Mounts the MCP endpoint, adds the API key collection and installs the
9
- * draft guard on every collection.
9
+ * draft guard on every collection and global.
10
10
  */ const mcpxPlugin = definePlugin({
11
11
  slug: "@abinnovision/payloadcms-mcpx",
12
12
  order: 100,
@@ -17,6 +17,7 @@ import { definePlugin } from "payload";
17
17
  return {
18
18
  ...config,
19
19
  collections: installDraftGuards([...config.collections ?? [], apiKeysCollection]),
20
+ globals: installGlobalDraftGuards(config.globals ?? []),
20
21
  endpoints: [
21
22
  ...config.endpoints ?? [],
22
23
  {
@@ -1,25 +1,26 @@
1
- import { blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
1
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
2
2
  //#region src/schema/describe.ts
3
3
  const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor) => descriptor.type === "blocks");
4
4
  /**
5
5
  * Walks a schema path to the field list it addresses.
6
6
  *
7
7
  * A schema path alternates a blocks field's own path with the slug of one of
8
- * the blocks it accepts, so `layout.sections.sectionWrapper.modules.hero`
9
- * reaches `hero` as it exists under `pages` specifically.
10
- */ const fieldsAtSchemaPath = (config, collection, schemaPath) => {
11
- let fields = collection.flattenedFields;
8
+ * the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
9
+ * reaches `hero` as it exists under `pages` specifically. The slug sits where
10
+ * a pointer into a document would carry the element's index.
11
+ */ const fieldsAtSchemaPath = (config, target, schemaPath) => {
12
+ let fields = target.flattenedFields;
12
13
  let blockType;
13
- let remaining = splitPath(schemaPath).filter(Boolean);
14
+ let remaining = splitPath(schemaPath);
14
15
  while (remaining.length > 0) {
15
16
  /**
16
17
  * A blocks field's own path may span several segments
17
- * (`layout.sections`), so the longest matching one is taken.
18
+ * (`/layout/sections`), so the longest matching one is taken.
18
19
  */ const match = blocksDescriptors(fields).map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
19
20
  if (!match) throw new Error(`"${joinPath(remaining)}" does not address a blocks field. Blocks fields here: ${blocksDescriptors(fields).map((descriptor) => descriptor.path).join(", ") || "none"}`);
20
21
  const slug = remaining.at(match.length);
21
22
  const field = findBlocksField(fields, match);
22
- if (!field) throw new Error(`"${match.join(".")}" could not be resolved.`);
23
+ if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
23
24
  if (slug === void 0) throw new Error(`"${joinPath(match)}" is a blocks field; append one of: ${blockSlugsOf(field).join(", ")}`);
24
25
  const block = blockOf(config, field, slug);
25
26
  if (!block) throw new Error(`"${slug}" is not allowed at "${joinPath(match)}". Allowed: ${blockSlugsOf(field).join(", ")}`);
@@ -33,28 +34,25 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
33
34
  };
34
35
  };
35
36
  /**
36
- * Describes a collection root, or one block reached through a schema path.
37
- */ const describeNode = (config, collection, schemaPath = "") => {
38
- const { blockType, fields } = fieldsAtSchemaPath(config, collectionOf(config, collection), schemaPath);
37
+ * Describes a collection or global root, or one block reached through a schema
38
+ * path.
39
+ */ const describeNode = (config, ref, schemaPath = "") => {
40
+ const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
39
41
  const descriptors = describeFields(fields);
40
- const next = descriptors.flatMap((descriptor) => (descriptor.blocks ?? []).map((slug) => [
41
- schemaPath,
42
- descriptor.path,
43
- slug
44
- ].filter(Boolean).join(".")));
42
+ const next = descriptors.flatMap((descriptor) => (descriptor.blocks ?? []).map((slug) => `${schemaPath}${descriptor.path}/${slug}`));
45
43
  return {
46
44
  ...blockType === void 0 ? {} : { blockType },
47
- collection,
45
+ ...ref.kind === "collection" ? { collection: ref.slug } : { global: ref.slug },
48
46
  fields: descriptors,
49
47
  ...next.length > 0 ? { next } : {},
50
48
  schemaPath
51
49
  };
52
50
  };
53
51
  /**
54
- * Every schema path reachable from a collection root, capped at
52
+ * Every schema path reachable from an entity root, capped at
55
53
  * {@link REACHABLE_PATHS_LIMIT}. `truncated` tells the caller the cap was hit
56
54
  * and explicit paths are the way to go deeper.
57
- */ const reachableSchemaPaths = (config, collection) => {
55
+ */ const reachableSchemaPaths = (config, ref) => {
58
56
  const seen = [];
59
57
  let truncated = false;
60
58
  const walk = (schemaPath, visited) => {
@@ -63,13 +61,9 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
63
61
  return;
64
62
  }
65
63
  seen.push(schemaPath);
66
- for (const descriptor of describeNode(config, collection, schemaPath).fields) for (const slug of descriptor.blocks ?? []) {
64
+ for (const descriptor of describeNode(config, ref, schemaPath).fields) for (const slug of descriptor.blocks ?? []) {
67
65
  if (visited.includes(slug)) continue;
68
- walk([
69
- schemaPath,
70
- descriptor.path,
71
- slug
72
- ].filter(Boolean).join("."), [...visited, slug]);
66
+ walk(`${schemaPath}${descriptor.path}/${slug}`, [...visited, slug]);
73
67
  }
74
68
  };
75
69
  walk("", []);
@@ -1,10 +1,7 @@
1
- import { blockOf, blockSlugsOf, collectionOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
1
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, splitPath, targetOf } from "./walk.mjs";
2
2
  //#region src/schema/pointer.ts
3
3
  const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
4
- /**
5
- * Decodes a JSON Pointer into its segments, unescaping `~1` and `~0`.
6
- */ const pointerSegments = (pointer) => pointer.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
7
- const partMatches = (part, segment) => segment !== void 0 && (part === "[]" ? isIndexSegment(segment) : part === segment);
4
+ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
8
5
  /**
9
6
  * Longest descriptor whose path is fully consumed by the leading segments.
10
7
  */ const longestMatch = (descriptors, segments) => descriptors.map((descriptor) => ({
@@ -35,10 +32,10 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "[]" ? is
35
32
  * can only be resolved by reading `blockType` off `sections[3]`, since a blocks
36
33
  * field admits many shapes at the same index.
37
34
  */ const resolveDataPointer = (config, target) => {
38
- let fields = collectionOf(config, target.collection).flattenedFields;
35
+ let fields = targetOf(config, target.ref).flattenedFields;
39
36
  let data = target.doc;
40
37
  let blockType;
41
- let segments = pointerSegments(target.pointer);
38
+ let segments = splitPath(target.pointer);
42
39
  while (segments.length > 0) {
43
40
  const descriptors = describeFields(fields);
44
41
  const match = longestMatch(descriptors, segments);
@@ -46,7 +43,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "[]" ? is
46
43
  if (isSubtreePrefix(descriptors, segments)) return {
47
44
  ...blockType === void 0 ? {} : { blockType },
48
45
  fields,
49
- prefix: joinPath(segments)
46
+ prefix: segments
50
47
  };
51
48
  throw new Error(`"${joinPath(segments)}" is not a field here. Available: ${descriptors.map((descriptor) => descriptor.path).join(", ")}`);
52
49
  }
@@ -55,9 +52,9 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "[]" ? is
55
52
  ...blockType === void 0 ? {} : { blockType },
56
53
  descriptor: match.descriptor,
57
54
  fields,
58
- prefix: ""
55
+ prefix: []
59
56
  };
60
- if (match.descriptor.type !== "blocks") throw new Error(`"${match.descriptor.path}" is a ${match.descriptor.type} field and has no "${rest.join("/")}" beneath it.`);
57
+ if (match.descriptor.type !== "blocks") throw new Error(`"${match.descriptor.path}" is a ${match.descriptor.type} field and has no "${joinPath(rest)}" beneath it.`);
61
58
  const [index, ...remaining] = rest;
62
59
  if (!isIndexSegment(index)) throw new Error(`"${match.descriptor.path}" is an array; "${index}" is not an index.`);
63
60
  const parts = splitPath(match.descriptor.path);
@@ -76,8 +73,8 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "[]" ? is
76
73
  return {
77
74
  ...blockType === void 0 ? {} : { blockType },
78
75
  fields,
79
- prefix: ""
76
+ prefix: []
80
77
  };
81
78
  };
82
79
  //#endregion
83
- export { pointerSegments, resolveDataPointer };
80
+ export { resolveDataPointer };
@@ -1,4 +1,4 @@
1
- import { blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, splitPath } from "./walk.mjs";
1
+ import { blockOf, blockSlugsOf, describeFields, findBlocksField, splitPath } from "./walk.mjs";
2
2
  //#region src/schema/shape.ts
3
3
  /**
4
4
  * Keys Payload manages on a row that a client may echo back harmlessly.
@@ -36,7 +36,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
36
36
  };
37
37
  const checkLeafValue = (scope, descriptor, value) => {
38
38
  if (descriptor.readOnly) {
39
- scope.problems.push(`${scope.pointer}: "${descriptor.path}" is read-only and cannot be written.`);
39
+ scope.problems.push(`${scope.pointer}: this field is read-only and cannot be written.`);
40
40
  return;
41
41
  }
42
42
  if (descriptor.type === "richText") {
@@ -61,7 +61,7 @@ const checkLeafValue = (scope, descriptor, value) => {
61
61
  ...scope,
62
62
  fields: block.flattenedFields,
63
63
  pointer: `${scope.pointer}/${String(index)}`,
64
- prefix: ""
64
+ prefix: []
65
65
  }, row);
66
66
  });
67
67
  };
@@ -76,7 +76,7 @@ const checkLeafValue = (scope, descriptor, value) => {
76
76
  * block would be stripped in silence.
77
77
  */ const checkValue = (scope, value) => {
78
78
  if (!isPlainObject(value)) return;
79
- const prefixParts = scope.prefix ? splitPath(scope.prefix) : [];
79
+ const prefixParts = scope.prefix;
80
80
  const relative = describeFields(scope.fields).flatMap((descriptor) => {
81
81
  const parts = splitPath(descriptor.path);
82
82
  return prefixParts.every((part, offset) => part === parts[offset]) ? [{
@@ -100,7 +100,7 @@ const checkLeafValue = (scope, descriptor, value) => {
100
100
  }, exact.descriptor, entry);
101
101
  continue;
102
102
  }
103
- if (candidates.some(({ parts }) => parts[1] === "[]")) {
103
+ if (candidates.some(({ parts }) => parts[1] === "*")) {
104
104
  if (!Array.isArray(entry)) {
105
105
  scope.problems.push(`${pointer}: expected an array.`);
106
106
  continue;
@@ -109,11 +109,11 @@ const checkLeafValue = (scope, descriptor, value) => {
109
109
  checkValue({
110
110
  ...scope,
111
111
  pointer: `${pointer}/${String(index)}`,
112
- prefix: joinPath([
112
+ prefix: [
113
113
  ...prefixParts,
114
114
  key,
115
- "[]"
116
- ])
115
+ "*"
116
+ ]
117
117
  }, row);
118
118
  });
119
119
  continue;
@@ -121,7 +121,7 @@ const checkLeafValue = (scope, descriptor, value) => {
121
121
  checkValue({
122
122
  ...scope,
123
123
  pointer,
124
- prefix: joinPath([...prefixParts, key])
124
+ prefix: [...prefixParts, key]
125
125
  }, entry);
126
126
  }
127
127
  };