@abinnovision/payloadcms-mcpx 1.0.0-beta.4 → 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 +65 -16
- package/dist/api-keys/fields.mjs +27 -12
- package/dist/capabilities.mjs +16 -3
- package/dist/endpoint/handler.mjs +3 -1
- package/dist/endpoint/server.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/options.d.mts +2 -0
- package/dist/options.mjs +38 -0
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +3 -2
- package/dist/schema/describe.mjs +11 -10
- package/dist/schema/pointer.mjs +2 -2
- package/dist/schema/walk.d.mts +1 -0
- package/dist/schema/walk.mjs +4 -4
- package/dist/tools/create-document.mjs +9 -8
- package/dist/tools/describe-schema.mjs +12 -6
- package/dist/tools/find-documents.mjs +4 -3
- package/dist/tools/get-document.mjs +24 -11
- package/dist/tools/list-capabilities.mjs +19 -1
- package/dist/tools/patch-document.mjs +34 -20
- package/dist/tools/shared.mjs +54 -21
- package/dist/tools/target.d.mts +3 -0
- package/dist/tools/target.mjs +49 -0
- package/dist/tools/types.d.mts +5 -0
- package/dist/tools/validate-document.mjs +22 -15
- package/dist/types.d.mts +25 -2
- package/dist/write/draft-guard.mjs +51 -8
- package/dist/write/patch.mjs +4 -4
- package/dist/write/publish-blockers.d.mts +1 -0
- package/dist/write/publish-blockers.mjs +8 -7
- package/package.json +1 -1
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
|
|
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,19 +121,20 @@ 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
|
|
122
|
-
reflects the key: write tools
|
|
123
|
-
|
|
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
|
|
131
|
-
| `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection
|
|
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
|
|
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
|
|
|
@@ -161,13 +165,55 @@ Rules the tools enforce and explain in their own descriptions:
|
|
|
161
165
|
`deletedAt`) are never listed and never writable; `readOnly` fields are
|
|
162
166
|
listed but refused on write.
|
|
163
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
|
+
|
|
164
208
|
## Drafts and publish blockers
|
|
165
209
|
|
|
166
210
|
Draft-only writing is enforced on the Payload operation, not in the tool
|
|
167
211
|
handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
|
|
168
212
|
from every write carrying the MCP request marker, so custom tools and anything
|
|
169
213
|
else writing through the same request are covered too. A `beforeChange` hook
|
|
170
|
-
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.
|
|
171
217
|
|
|
172
218
|
Publish blockers are advisory. Payload skips validation on draft saves (unless
|
|
173
219
|
`versions.drafts.validate` is set), so after every write the plugin re-runs
|
|
@@ -224,6 +270,10 @@ Builtin tools reject them instead.
|
|
|
224
270
|
| `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
|
|
225
271
|
| `collections.<slug>.write` | `false` | Expose `patchDocument`, `createDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
|
|
226
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). |
|
|
227
277
|
| `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
|
|
228
278
|
| `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
|
|
229
279
|
| `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
|
|
@@ -247,15 +297,14 @@ key of every user.
|
|
|
247
297
|
- The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
|
|
248
298
|
ignored. Keys cannot authenticate REST or GraphQL.
|
|
249
299
|
- Every operation runs under the linked user with `overrideAccess: false`.
|
|
250
|
-
- Not covered in v1: `delete` (no tool exists and none is generated),
|
|
251
|
-
|
|
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.
|
|
252
302
|
|
|
253
303
|
## Non-goals of v1 / roadmap
|
|
254
304
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
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.
|
|
259
308
|
|
|
260
309
|
## License
|
|
261
310
|
|
package/dist/api-keys/fields.mjs
CHANGED
|
@@ -56,9 +56,10 @@ const checkbox = (name, description) => ({
|
|
|
56
56
|
}
|
|
57
57
|
];
|
|
58
58
|
/**
|
|
59
|
-
* One checkbox per exposed operation, grouped per collection
|
|
60
|
-
* tool. Only operations the plugin config exposes get a checkbox, so
|
|
61
|
-
* never enable more than the config allows. Everything defaults to
|
|
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
|
|
70
|
-
|
|
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
|
-
|
|
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,
|
package/dist/capabilities.mjs
CHANGED
|
@@ -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
|
|
28
|
-
const
|
|
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
|
};
|
package/dist/endpoint/server.mjs
CHANGED
|
@@ -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 };
|
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,
|
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
|
{
|
package/dist/schema/describe.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { blockOf, blockSlugsOf,
|
|
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
|
/**
|
|
@@ -8,8 +8,8 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
|
|
|
8
8
|
* the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
|
|
9
9
|
* reaches `hero` as it exists under `pages` specifically. The slug sits where
|
|
10
10
|
* a pointer into a document would carry the element's index.
|
|
11
|
-
*/ const fieldsAtSchemaPath = (config,
|
|
12
|
-
let fields =
|
|
11
|
+
*/ const fieldsAtSchemaPath = (config, target, schemaPath) => {
|
|
12
|
+
let fields = target.flattenedFields;
|
|
13
13
|
let blockType;
|
|
14
14
|
let remaining = splitPath(schemaPath);
|
|
15
15
|
while (remaining.length > 0) {
|
|
@@ -34,24 +34,25 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
|
|
|
34
34
|
};
|
|
35
35
|
};
|
|
36
36
|
/**
|
|
37
|
-
* Describes a collection root, or one block reached through a schema
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
|
40
41
|
const descriptors = describeFields(fields);
|
|
41
42
|
const next = descriptors.flatMap((descriptor) => (descriptor.blocks ?? []).map((slug) => `${schemaPath}${descriptor.path}/${slug}`));
|
|
42
43
|
return {
|
|
43
44
|
...blockType === void 0 ? {} : { blockType },
|
|
44
|
-
collection,
|
|
45
|
+
...ref.kind === "collection" ? { collection: ref.slug } : { global: ref.slug },
|
|
45
46
|
fields: descriptors,
|
|
46
47
|
...next.length > 0 ? { next } : {},
|
|
47
48
|
schemaPath
|
|
48
49
|
};
|
|
49
50
|
};
|
|
50
51
|
/**
|
|
51
|
-
* Every schema path reachable from
|
|
52
|
+
* Every schema path reachable from an entity root, capped at
|
|
52
53
|
* {@link REACHABLE_PATHS_LIMIT}. `truncated` tells the caller the cap was hit
|
|
53
54
|
* and explicit paths are the way to go deeper.
|
|
54
|
-
*/ const reachableSchemaPaths = (config,
|
|
55
|
+
*/ const reachableSchemaPaths = (config, ref) => {
|
|
55
56
|
const seen = [];
|
|
56
57
|
let truncated = false;
|
|
57
58
|
const walk = (schemaPath, visited) => {
|
|
@@ -60,7 +61,7 @@ const blocksDescriptors = (fields) => describeFields(fields).filter((descriptor)
|
|
|
60
61
|
return;
|
|
61
62
|
}
|
|
62
63
|
seen.push(schemaPath);
|
|
63
|
-
for (const descriptor of describeNode(config,
|
|
64
|
+
for (const descriptor of describeNode(config, ref, schemaPath).fields) for (const slug of descriptor.blocks ?? []) {
|
|
64
65
|
if (visited.includes(slug)) continue;
|
|
65
66
|
walk(`${schemaPath}${descriptor.path}/${slug}`, [...visited, slug]);
|
|
66
67
|
}
|
package/dist/schema/pointer.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { blockOf, blockSlugsOf,
|
|
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
4
|
const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isIndexSegment(segment) : part === segment);
|
|
@@ -32,7 +32,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
32
32
|
* can only be resolved by reading `blockType` off `sections[3]`, since a blocks
|
|
33
33
|
* field admits many shapes at the same index.
|
|
34
34
|
*/ const resolveDataPointer = (config, target) => {
|
|
35
|
-
let fields =
|
|
35
|
+
let fields = targetOf(config, target.ref).flattenedFields;
|
|
36
36
|
let data = target.doc;
|
|
37
37
|
let blockType;
|
|
38
38
|
let segments = splitPath(target.pointer);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "payload";
|
package/dist/schema/walk.mjs
CHANGED
|
@@ -109,10 +109,10 @@ const withRows = (descriptor, field) => ({
|
|
|
109
109
|
if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
|
|
110
110
|
}
|
|
111
111
|
};
|
|
112
|
-
const
|
|
113
|
-
const found = config.collections.find((candidate) => candidate.slug ===
|
|
114
|
-
if (!found) throw new Error(`Unknown
|
|
112
|
+
const targetOf = (config, ref) => {
|
|
113
|
+
const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
|
|
114
|
+
if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
|
|
115
115
|
return found;
|
|
116
116
|
};
|
|
117
117
|
//#endregion
|
|
118
|
-
export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf,
|
|
118
|
+
export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeFields, findBlocksField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errorResult, jsonResult } from "../endpoint/result.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
|
|
3
|
+
import { resolveTarget } from "./target.mjs";
|
|
3
4
|
import { validateWriteValue } from "../schema/shape.mjs";
|
|
4
5
|
import { stripRowIds } from "../write/patch.mjs";
|
|
5
6
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
@@ -16,7 +17,7 @@ const createDocument = {
|
|
|
16
17
|
},
|
|
17
18
|
isEnabled: (scope) => scope.writable.length > 0,
|
|
18
19
|
inputSchema: (scope) => ({
|
|
19
|
-
collection:
|
|
20
|
+
collection: slugEnum(scope.writable).describe("Collection to create the document in."),
|
|
20
21
|
...localeShape(scope, {
|
|
21
22
|
required: true,
|
|
22
23
|
description: "Locale the localized fields of the seed belong to."
|
|
@@ -24,14 +25,14 @@ const createDocument = {
|
|
|
24
25
|
data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
|
|
25
26
|
}),
|
|
26
27
|
handler: async (args, scope) => {
|
|
27
|
-
const
|
|
28
|
+
const target = resolveTarget(scope, { collection: args.collection }, "write");
|
|
28
29
|
const { payload } = scope.req;
|
|
29
30
|
const locale = localeOf(scope, args.locale);
|
|
30
31
|
const { id: _ignored, ...seed } = args.data;
|
|
31
32
|
const problems = validateWriteValue(payload.config, {
|
|
32
33
|
pointer: "",
|
|
33
34
|
resolution: {
|
|
34
|
-
fields:
|
|
35
|
+
fields: target.config.flattenedFields,
|
|
35
36
|
prefix: []
|
|
36
37
|
}
|
|
37
38
|
}, seed);
|
|
@@ -45,15 +46,15 @@ const createDocument = {
|
|
|
45
46
|
req: scope.req,
|
|
46
47
|
...locale === void 0 ? {} : { locale }
|
|
47
48
|
});
|
|
48
|
-
const saved = await
|
|
49
|
-
|
|
49
|
+
const saved = await readTarget(scope, {
|
|
50
|
+
target,
|
|
50
51
|
id: created["id"],
|
|
51
52
|
locale,
|
|
52
53
|
privileged: true
|
|
53
54
|
});
|
|
54
55
|
const publishBlockers = await collectPublishBlockers(scope.req, {
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
doc: saved,
|
|
57
|
+
entity: target
|
|
57
58
|
});
|
|
58
59
|
return jsonResult({
|
|
59
60
|
id: saved["id"],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsonResult } from "../endpoint/result.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { targetShape } from "./shared.mjs";
|
|
3
|
+
import { refOf, resolveTarget } from "./target.mjs";
|
|
3
4
|
import { describeNode, reachableSchemaPaths } from "../schema/describe.mjs";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
//#region src/tools/describe-schema.ts
|
|
@@ -7,6 +8,8 @@ const describeSchema = {
|
|
|
7
8
|
name: "describeSchema",
|
|
8
9
|
description: `Describes the writable shape of a document, one node at a time.
|
|
9
10
|
|
|
11
|
+
Pass exactly one of "collection" and "global". A global is a singleton: it has no id, is not listed by findDocuments and cannot be created.
|
|
12
|
+
|
|
10
13
|
Call it with no "paths" to get a collection's own fields. Every "blocks" field stops there and lists the block slugs it accepts instead of nesting them; each node's "next" lists the ready-to-use paths for those blocks, so pass any entry of "next" as a "paths" element to descend, e.g. "/layout/sections/sectionWrapper" and then "/layout/sections/sectionWrapper/modules/hero". A block is described as it exists at that position, because the same block can accept different children elsewhere.
|
|
11
14
|
|
|
12
15
|
Paths here use the same JSON Pointer syntax as getDocument and patchDocument, and are already resolved through anything that does not nest in the stored document. The difference is only what stands in an element position: a path names an array element "*" and a block by its slug, where a pointer into a document carries a 0-based index. So "/items/*/title" is written at "/items/0/title", and "/layout/sections/hero" at "/layout/sections/0".
|
|
@@ -16,19 +19,22 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
|
|
|
16
19
|
readOnlyHint: true,
|
|
17
20
|
openWorldHint: false
|
|
18
21
|
},
|
|
19
|
-
isEnabled: (scope) => scope.readable.length > 0,
|
|
22
|
+
isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
|
|
20
23
|
inputSchema: (scope) => ({
|
|
21
|
-
|
|
24
|
+
...targetShape(scope, "read", {
|
|
25
|
+
collection: "Collection to describe.",
|
|
26
|
+
global: "Global to describe."
|
|
27
|
+
}),
|
|
22
28
|
paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"/layout/sections/sectionWrapper\". Omit for the collection root."),
|
|
23
29
|
expand: z.boolean().optional().describe("Return every node reachable from the root in one response. Ignores paths.")
|
|
24
30
|
}),
|
|
25
31
|
handler: (args, scope) => {
|
|
26
|
-
|
|
32
|
+
const ref = refOf(resolveTarget(scope, args, "read"));
|
|
27
33
|
const { config } = scope.req.payload;
|
|
28
|
-
const expanded = args.expand === true ? reachableSchemaPaths(config,
|
|
34
|
+
const expanded = args.expand === true ? reachableSchemaPaths(config, ref) : void 0;
|
|
29
35
|
const nodes = (expanded?.paths ?? (args.paths && args.paths.length > 0 ? args.paths : [""])).map((schemaPath) => {
|
|
30
36
|
try {
|
|
31
|
-
return describeNode(config,
|
|
37
|
+
return describeNode(config, ref, schemaPath);
|
|
32
38
|
} catch (error) {
|
|
33
39
|
return {
|
|
34
40
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsonResult } from "../endpoint/result.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
|
|
3
|
+
import { resolveTarget } from "./target.mjs";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
//#region src/tools/find-documents.ts
|
|
5
6
|
const findDocuments = {
|
|
@@ -11,7 +12,7 @@ const findDocuments = {
|
|
|
11
12
|
},
|
|
12
13
|
isEnabled: (scope) => scope.readable.length > 0,
|
|
13
14
|
inputSchema: (scope) => ({
|
|
14
|
-
collection:
|
|
15
|
+
collection: slugEnum(scope.readable).describe("Collection to search."),
|
|
15
16
|
where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
|
|
16
17
|
sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
|
|
17
18
|
limit: z.number().int().min(1).max(scope.options.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.options.limits.maxLimit)}.`),
|
|
@@ -25,7 +26,7 @@ const findDocuments = {
|
|
|
25
26
|
draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
|
|
26
27
|
}),
|
|
27
28
|
handler: async (args, scope) => {
|
|
28
|
-
|
|
29
|
+
resolveTarget(scope, { collection: args.collection }, "read");
|
|
29
30
|
const locale = localeOf(scope, args.locale);
|
|
30
31
|
const result = await scope.req.payload.find({
|
|
31
32
|
collection: args.collection,
|