@abinnovision/payloadcms-mcpx 1.0.0-beta.8 → 1.0.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 +33 -0
- package/README.md +331 -194
- package/dist/api-keys/collection.mjs +3 -3
- package/dist/api-keys/fields.mjs +56 -5
- package/dist/api-keys/setup-guide.mjs +56 -0
- package/dist/auth/resolve.mjs +5 -7
- package/dist/capabilities.mjs +23 -4
- package/dist/client/index.d.mts +2 -0
- package/dist/client/index.mjs +2 -0
- package/dist/client/setup-guide.d.mts +14 -0
- package/dist/client/setup-guide.mjs +87 -0
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
- package/dist/endpoint/handler.mjs +11 -5
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +18 -26
- package/dist/i18n.mjs +4 -15
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -3
- package/dist/options.mjs +31 -26
- package/dist/plugin.mjs +1 -0
- package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
- package/dist/request.mjs +8 -0
- package/dist/result.d.mts +11 -0
- package/dist/result.mjs +20 -0
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +8 -0
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +195 -27
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +77 -30
- package/dist/schema/shape.mjs +133 -51
- package/dist/schema/walk.mjs +44 -64
- package/dist/tools/{index.mjs → builtin.mjs} +8 -5
- package/dist/tools/create-document.mjs +34 -15
- package/dist/tools/describe-schema.mjs +21 -7
- package/dist/tools/find-documents.mjs +13 -6
- package/dist/tools/get-document.mjs +45 -11
- package/dist/tools/list-capabilities.mjs +19 -9
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +32 -21
- package/dist/tools/publish-document.mjs +79 -0
- package/dist/tools/shared.mjs +84 -32
- package/dist/tools/target.mjs +7 -11
- package/dist/tools/validate-document.mjs +20 -12
- package/dist/types.d.mts +115 -42
- package/dist/types.mjs +3 -4
- package/dist/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +47 -44
- package/dist/write/patch.mjs +174 -92
- package/dist/write/publish-blockers.mjs +13 -12
- package/dist/write/publish-intent.mjs +17 -0
- package/dist/write/transaction.mjs +8 -3
- package/package.json +24 -9
- package/dist/i18n.d.mts +0 -1
- package/dist/options.d.mts +0 -2
- package/dist/schema/lexical.d.mts +0 -1
- package/dist/schema/walk.d.mts +0 -3
- package/dist/tools/target.d.mts +0 -3
- package/dist/tools/types.d.mts +0 -5
- package/dist/write/publish-blockers.d.mts +0 -15
package/README.md
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
# @abinnovision/payloadcms-mcpx
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
resolved server-side against the real config and
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
the draft. Capabilities are declared twice: the plugin config decides what
|
|
20
|
-
can exist, a checkbox on each API key decides what does, and a missing checkbox
|
|
21
|
-
means no (fail-closed).
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A Payload CMS plugin that mounts an MCP (Model Context Protocol) server over the
|
|
6
|
+
content model. The tool surface stays fixed at eight tools plus your own,
|
|
7
|
+
whatever the size of that model.
|
|
8
|
+
|
|
9
|
+
- Field shapes are pulled on demand through `describeSchema`, one node at a
|
|
10
|
+
time, rather than inlined into tool signatures. Adding a collection changes an
|
|
11
|
+
enum, never the tool list.
|
|
12
|
+
- Writes are RFC 6902 patches resolved server-side against the real config and
|
|
13
|
+
the real document. An unknown field, a misplaced block or an unusable rich
|
|
14
|
+
text node comes back refused, with the valid alternatives listed.
|
|
15
|
+
- Every write lands as a draft unless the config says otherwise, and reports the
|
|
16
|
+
publish blockers still standing between that draft and a publish.
|
|
17
|
+
- Capabilities are declared twice. The plugin config decides what can exist, a
|
|
18
|
+
checkbox on each API key decides what does, and a missing checkbox reads as no.
|
|
22
19
|
|
|
23
20
|
## Install
|
|
24
21
|
|
|
@@ -27,10 +24,16 @@ yarn add @abinnovision/payloadcms-mcpx
|
|
|
27
24
|
```
|
|
28
25
|
|
|
29
26
|
- Peer dependency: `payload >=3.88.0 <4`.
|
|
27
|
+
- `@payloadcms/ui` and `react` are optional peers, needed only by the admin
|
|
28
|
+
setup guide. A headless install can leave them out and set
|
|
29
|
+
`apiKeys.setupGuide: false`.
|
|
30
30
|
- The package is published as ESM only, matching Payload itself.
|
|
31
31
|
|
|
32
32
|
## Usage
|
|
33
33
|
|
|
34
|
+
Name the collections and globals the plugin may reach. Nothing outside this list
|
|
35
|
+
is exposed:
|
|
36
|
+
|
|
34
37
|
```ts
|
|
35
38
|
import { mcpxPlugin } from "@abinnovision/payloadcms-mcpx";
|
|
36
39
|
import { buildConfig } from "payload";
|
|
@@ -40,12 +43,12 @@ export default buildConfig({
|
|
|
40
43
|
plugins: [
|
|
41
44
|
mcpxPlugin({
|
|
42
45
|
collections: {
|
|
43
|
-
pages: { read: true, write:
|
|
44
|
-
posts: { read: true, write:
|
|
46
|
+
pages: { read: true, write: "live" }, // may be published through MCP
|
|
47
|
+
posts: { read: true, write: "draft" }, // drafts only
|
|
45
48
|
tags: true, // shorthand for { read: true }
|
|
46
49
|
},
|
|
47
50
|
globals: {
|
|
48
|
-
"site-settings": { read: true, write:
|
|
51
|
+
"site-settings": { read: true, write: "live" },
|
|
49
52
|
},
|
|
50
53
|
limits: { maxLimit: 25, maxDepth: 1 },
|
|
51
54
|
}),
|
|
@@ -55,37 +58,19 @@ export default buildConfig({
|
|
|
55
58
|
|
|
56
59
|
The plugin adds:
|
|
57
60
|
|
|
58
|
-
- a `POST /api/mcpx` endpoint speaking MCP over streamable HTTP (stateless,
|
|
59
|
-
|
|
60
|
-
- an `mcpx-api-keys` collection
|
|
61
|
-
capability checkboxes,
|
|
61
|
+
- a `POST /api/mcpx` endpoint speaking MCP over streamable HTTP (stateless, JSON
|
|
62
|
+
responses; `GET` and `DELETE` answer 405),
|
|
63
|
+
- an `mcpx-api-keys` collection under the admin group "MCP", holding the keys
|
|
64
|
+
and their capability checkboxes,
|
|
62
65
|
- a draft guard on every collection and global, so any write carrying the MCP
|
|
63
|
-
request marker lands as a draft,
|
|
64
|
-
|
|
65
|
-
## API keys
|
|
66
|
-
|
|
67
|
-
Keys are created in the admin panel under MCP > API Keys. The plaintext key is
|
|
68
|
-
generated on create, stored encrypted with an HMAC index for lookup, and shown
|
|
69
|
-
to anyone who may read the key document (own keys only, by default). Each key:
|
|
70
|
-
|
|
71
|
-
- is bound to the user who created it and acts as that user: every operation
|
|
72
|
-
runs with `req.user` set to the linked user and `overrideAccess: false`, so
|
|
73
|
-
your collection access control applies unchanged;
|
|
74
|
-
- carries one checkbox per exposed collection and operation, plus one per
|
|
75
|
-
custom tool. All checkboxes default to off. A key can never enable an
|
|
76
|
-
operation the plugin config does not expose, and keys created before a
|
|
77
|
-
capability existed stay without it.
|
|
78
|
-
|
|
79
|
-
Keys authenticate only the MCP endpoint. They are deliberately not a Payload
|
|
80
|
-
auth strategy, so a key can never authenticate the REST or GraphQL API; the
|
|
81
|
-
reverse also holds: an admin session or JWT is ignored by the MCP endpoint.
|
|
82
|
-
|
|
83
|
-
Use `apiKeys.overrideCollection` to widen access (for example, admins manage
|
|
84
|
-
all keys) or add fields.
|
|
66
|
+
request marker lands as a draft, custom tools included.
|
|
85
67
|
|
|
86
|
-
|
|
68
|
+
Create a key in the admin panel under MCP > API Keys, tick the capabilities it
|
|
69
|
+
should have, and copy the plaintext key shown after saving. Checkboxes default
|
|
70
|
+
to off, so a fresh key can do nothing until you say otherwise. See
|
|
71
|
+
[API keys](#api-keys) for what a key is and is not.
|
|
87
72
|
|
|
88
|
-
|
|
73
|
+
Then point a client at the endpoint, passing the key as a bearer token:
|
|
89
74
|
|
|
90
75
|
```bash
|
|
91
76
|
npx @modelcontextprotocol/inspector
|
|
@@ -100,7 +85,8 @@ claude mcp add --transport http payload http://localhost:3000/api/mcpx \
|
|
|
100
85
|
--header "Authorization: Bearer <key>"
|
|
101
86
|
```
|
|
102
87
|
|
|
103
|
-
Claude Desktop
|
|
88
|
+
Claude Desktop has no direct HTTP header support, so it goes through
|
|
89
|
+
`mcp-remote`:
|
|
104
90
|
|
|
105
91
|
```json
|
|
106
92
|
{
|
|
@@ -119,71 +105,181 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
|
|
|
119
105
|
}
|
|
120
106
|
```
|
|
121
107
|
|
|
108
|
+
## Options
|
|
109
|
+
|
|
110
|
+
| Option | Type | Default | Description |
|
|
111
|
+
| ---------------------------- | ------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------- |
|
|
112
|
+
| `collections` | `Record<slug, options \| true>` | required | Allow-list. `true` means `{ read: true }`. |
|
|
113
|
+
| `collections.<slug>.read` | `boolean` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
|
|
114
|
+
| `collections.<slug>.write` | `"draft" \| "live" \| false` | `false` | How far writes reach. See below. |
|
|
115
|
+
| `globals` | `Record<slug, options \| true>` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
|
|
116
|
+
| `globals.<slug>.read` | `boolean` | `true` | Expose `describeSchema`, `getDocument`. |
|
|
117
|
+
| `globals.<slug>.write` | `"draft" \| "live" \| false` | `false` | How far writes reach. See below. |
|
|
118
|
+
| `userCollection` | `string` | `config.admin.user`, then `users` | Auth collection the keys act as. |
|
|
119
|
+
| `apiKeys.slug` | `string` | `mcpx-api-keys` | Slug of the generated key collection. |
|
|
120
|
+
| `apiKeys.setupGuide` | `boolean` | `true` | Add a "Connect a client" tab to saved keys. Needs the import map. |
|
|
121
|
+
| `apiKeys.overrideCollection` | `(c: CollectionConfig) => CollectionConfig` | — | Final override applied to the generated collection. |
|
|
122
|
+
| `endpoint.path` | `string` | `/mcpx` | Endpoint path below the API route. |
|
|
123
|
+
| `limits.maxLimit` | `number` | `25` | Upper bound for `findDocuments.limit`. |
|
|
124
|
+
| `limits.maxDepth` | `number` | `1` | Upper bound for `depth` on reads. |
|
|
125
|
+
| `tools` | `McpxTool[]` | `[]` | Custom tools, defined the same way as the builtins. |
|
|
126
|
+
| `auth.resolve` | `(args) => Promise<McpxAuthResult \| null>` | — | Replace or wrap the default key resolution. |
|
|
127
|
+
| `serverInfo` | `{ name?, version? }` | `payloadcms-mcpx` and the package version | Reported to MCP clients. |
|
|
128
|
+
|
|
129
|
+
### Write modes
|
|
130
|
+
|
|
131
|
+
`write` is one axis: how far MCP writes to this entity reach.
|
|
132
|
+
|
|
133
|
+
| `write` | With `versions.drafts` | Without |
|
|
134
|
+
| --------- | ------------------------------------------------------- | ---------------------------------------------- |
|
|
135
|
+
| `false` | no write tool reaches it | no write tool reaches it |
|
|
136
|
+
| `"draft"` | writes land as drafts, nothing is ever published | refused at startup: there is no draft to write |
|
|
137
|
+
| `"live"` | writes land as drafts, and `publishDocument` is exposed | writes land on the live document |
|
|
138
|
+
|
|
139
|
+
`"live"` is the only way an MCP write reaches live content, whichever of the two
|
|
140
|
+
shapes it takes. Wherever it is set, the server instructions and the
|
|
141
|
+
`patchDocument` and `createDocument` descriptions name those slugs for the key in
|
|
142
|
+
question, so a client is never told its writes are drafts while they are not.
|
|
143
|
+
|
|
144
|
+
### Upload collections
|
|
145
|
+
|
|
146
|
+
An upload collection may be exposed for write. `patchDocument` and
|
|
147
|
+
`validateDocument` reach it, and `publishDocument` under the same `write:
|
|
148
|
+
"live"` rule as anywhere else, so an agent can edit the fields the collection
|
|
149
|
+
declares itself, such as `alt` or a credit.
|
|
150
|
+
|
|
151
|
+
Its base fields (`filename`, `url`, `filesize`, `sizes`, the focal point) are
|
|
152
|
+
neither described nor writable. `createDocument` leaves the slug out of its
|
|
153
|
+
`collection` enum and says why in its description: a create there would have to
|
|
154
|
+
carry the file, and no tool does. Upload the file in the admin panel first.
|
|
155
|
+
|
|
156
|
+
### Startup validation
|
|
157
|
+
|
|
158
|
+
Misconfiguration fails at startup with `InvalidConfiguration`: unknown slugs,
|
|
159
|
+
`write: "draft"` on a collection without drafts, `write` on a collection with
|
|
160
|
+
`timestamps: false`, tool name collisions, and `write: "live"` on an entity
|
|
161
|
+
using `versions.drafts.localizeStatus`, which is not supported yet.
|
|
162
|
+
|
|
163
|
+
Auth collections cannot be exposed at all, read included. Their documents carry
|
|
164
|
+
credentials, such as the decrypted Payload API key of every user.
|
|
165
|
+
|
|
122
166
|
## Tools
|
|
123
167
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
| Tool | Purpose
|
|
130
|
-
| ------------------ |
|
|
131
|
-
| `listCapabilities` | What this key may do
|
|
132
|
-
| `describeSchema` | Field shape of one node; `next` lists the drill-down paths.
|
|
133
|
-
| `findDocuments` | Query documents.
|
|
134
|
-
| `getDocument` | Read one document or a subtree of it.
|
|
135
|
-
| `patchDocument` | Apply RFC 6902 operations to the current draft.
|
|
136
|
-
| `createDocument` | Create a draft from a minimal seed.
|
|
137
|
-
| `validateDocument` | Publish blockers without
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
`tools/list` reflects the key: write tools disappear for read-only keys, and
|
|
169
|
+
every `collection` and `global` enum contains only the slugs the key may touch.
|
|
170
|
+
Builtin tools reject unknown arguments by name instead of silently ignoring
|
|
171
|
+
them.
|
|
172
|
+
|
|
173
|
+
| Tool | Purpose | Key arguments |
|
|
174
|
+
| ------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
|
175
|
+
| `listCapabilities` | What this key may do, `create` apart from `write`; call first to orient. | none |
|
|
176
|
+
| `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection` \| `global`, `paths?`, `expand?` |
|
|
177
|
+
| `findDocuments` | Query documents. | `collection`, `where?`, `sort?`, `limit?`, `page?`, `depth?`, `select?`, `locale?`, `draft?` |
|
|
178
|
+
| `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?`, `depth?`, `locale?`, `draft?`, `outline?` |
|
|
179
|
+
| `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
|
|
180
|
+
| `createDocument` | Create a draft from a minimal seed. Not for upload collections. | `collection`, `locale`, `data` |
|
|
181
|
+
| `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
|
|
182
|
+
| `publishDocument` | Publish the current draft. | `collection` + `id` \| `global`, `expectedUpdatedAt?` |
|
|
183
|
+
|
|
184
|
+
### Paths and pointers
|
|
185
|
+
|
|
186
|
+
Every path this plugin accepts or reports is a JSON Pointer. A schema path and a
|
|
187
|
+
pointer into a document differ only in what stands in an element position: a
|
|
188
|
+
schema path writes `*` for an array element and names a block by its slug, where
|
|
189
|
+
a pointer carries a 0-based index. So `/items/*/title` is written at
|
|
190
|
+
`/items/0/title`, and `/layout/sections/hero` at `/layout/sections/0`.
|
|
191
|
+
|
|
192
|
+
Inside a rich text field that substitution does not apply, because an editor
|
|
193
|
+
state is a tree rather than a list per type. A path there names the node type,
|
|
194
|
+
and a block node its slug. A pointer enters the state at `root` and walks
|
|
195
|
+
`children` by an index counted over every child at that level, with the node's
|
|
196
|
+
own fields under `fields`. So the path `/content/block/practice-note/variant` is
|
|
197
|
+
written at the pointer `/content/root/children/7/fields/variant`, and only the
|
|
198
|
+
stored state says which index that is. `getDocument` with `outline` answers
|
|
199
|
+
that.
|
|
200
|
+
|
|
201
|
+
### Reading the schema
|
|
202
|
+
|
|
203
|
+
- Paths stop at blocks fields, which list the block slugs they accept. Every
|
|
204
|
+
node carries `next`, the ready-to-use paths for those blocks
|
|
143
205
|
(`/layout/sections/sectionWrapper`), so pass an entry of `next` as a `paths`
|
|
144
206
|
element to descend. A block is described as it exists at that position.
|
|
145
|
-
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
`describeSchema` and `listCapabilities`, so intent written for the admin
|
|
166
|
-
panel reaches the client. A locale-keyed record is resolved to one string for
|
|
167
|
-
the request's language, falling back to the deployment's fallback language and
|
|
168
|
-
then to the record's first entry; functions and components are dropped.
|
|
169
|
-
- Builtin tools reject unknown arguments by name instead of silently ignoring
|
|
170
|
-
them.
|
|
171
|
-
- Every path this plugin accepts or reports is a JSON Pointer. A schema path
|
|
172
|
-
and a pointer into a document differ only in what stands in an element
|
|
173
|
-
position: a schema path writes `*` for an array element and names a block by
|
|
174
|
-
its slug, where a pointer carries a 0-based index. So `/items/*/title` is
|
|
175
|
-
written at `/items/0/title`, and `/layout/sections/hero` at
|
|
176
|
-
`/layout/sections/0`.
|
|
177
|
-
- Adding a block requires `blockType` on the value; append with `/-`.
|
|
178
|
-
- Clearing is `replace` with `null`; a list is emptied with `[]` and refuses
|
|
179
|
-
`null`. `remove` is only valid on list elements, because Payload keeps
|
|
180
|
-
fields absent from a write.
|
|
207
|
+
- A field marked `admin.hidden` is neither described nor writable. Payload keeps
|
|
208
|
+
such a field out of the admin panel only, where this plugin keeps it from the
|
|
209
|
+
client as well.
|
|
210
|
+
- Constraints a field declares travel with it: `minRows` and `maxRows` on arrays
|
|
211
|
+
and blocks fields, `maxLength` and `minLength` on text, `min` and `max` on
|
|
212
|
+
numbers. An array is described in its own right, so the `*` in `/items/*/title`
|
|
213
|
+
has something to read. A group or named tab is described only when it declares
|
|
214
|
+
a description or a constraint of its own.
|
|
215
|
+
- Field and collection `admin.description` values reach `describeSchema` and
|
|
216
|
+
`listCapabilities`, so intent written for the admin panel reaches the client. A
|
|
217
|
+
locale-keyed record resolves to one string for the request's language, falling
|
|
218
|
+
back to the deployment's fallback language and then to the record's first
|
|
219
|
+
entry. Functions and components are dropped.
|
|
220
|
+
|
|
221
|
+
### Patching
|
|
222
|
+
|
|
223
|
+
- Adding a block requires `blockType` on the value. Append with `/-`.
|
|
224
|
+
- Clearing is `replace` with `null`. A list is emptied with `[]` and refuses
|
|
225
|
+
`null`. `remove` is only valid on list elements, because Payload keeps fields
|
|
226
|
+
absent from a write.
|
|
181
227
|
- Nothing in a patch batch is applied unless every operation validates first.
|
|
182
228
|
- Pass the `updatedAt` you read as `expectedUpdatedAt` so a concurrent edit is
|
|
183
229
|
refused instead of overwritten.
|
|
184
230
|
- Fields Payload maintains (`id`, `_status`, `createdAt`, `updatedAt`,
|
|
185
|
-
`deletedAt`) are never listed and never writable
|
|
186
|
-
|
|
231
|
+
`deletedAt`) are never listed and never writable. `readOnly` fields are listed
|
|
232
|
+
but refused on write.
|
|
233
|
+
|
|
234
|
+
### Rich text
|
|
235
|
+
|
|
236
|
+
A `richText` field lists the Lexical node types it accepts in `nodes`, and
|
|
237
|
+
`next` carries a path for every node type that holds fields of its own:
|
|
238
|
+
`/content/link` for a link node, `/content/block/callout` and
|
|
239
|
+
`/content/inlineBlock/badge` for the block nodes. Descending returns the real
|
|
240
|
+
field list. `upload` nodes are the exception, since their fields depend on the
|
|
241
|
+
collection the node points at, so they are not addressable.
|
|
242
|
+
|
|
243
|
+
A field also reports `nodeOptions`, the node properties its editor narrows. An
|
|
244
|
+
editor built with `HeadingFeature({ enabledHeadingSizes: ["h4"] })` answers
|
|
245
|
+
`{ "heading": { "tag": ["h4"] } }`, and a write carrying any other heading tag is
|
|
246
|
+
refused. Lexical stores whatever tag it is given, so this is the only place the
|
|
247
|
+
restriction is checked.
|
|
248
|
+
|
|
249
|
+
A node must be written the way Lexical serializes it, carrying the values
|
|
250
|
+
Lexical would have written. Payload does not check that on write, so this plugin
|
|
251
|
+
does, and the refusal names the property and what belongs there. A
|
|
252
|
+
`describeSchema` response that reached a `richText` field ends with a
|
|
253
|
+
`nodeProperties` entry stating what each node type has to carry, keyed by node
|
|
254
|
+
type and in the same words the refusal uses. Its `text` entry reads:
|
|
255
|
+
|
|
256
|
+
```json
|
|
257
|
+
{
|
|
258
|
+
"detail": "a number",
|
|
259
|
+
"format": "a number",
|
|
260
|
+
"mode": "a string",
|
|
261
|
+
"style": "a string",
|
|
262
|
+
"text": "a string",
|
|
263
|
+
"type": "a string",
|
|
264
|
+
"version": "a number"
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
A field's value is addressable, so a small edit does not have to rewrite the
|
|
269
|
+
whole state. `/content/root/children/2` is a node,
|
|
270
|
+
`/content/root/children/2/tag` one of its properties, and
|
|
271
|
+
`/content/root/children/2/fields/url` a field the node carries. The root and a
|
|
272
|
+
node's `type` cannot be replaced on their own, and a node property cannot be
|
|
273
|
+
removed. A state whose root holds nothing is refused however it is written,
|
|
274
|
+
since Lexical reads it as empty and throws rather than rendering it; an empty
|
|
275
|
+
field is stored as null instead.
|
|
276
|
+
|
|
277
|
+
Node positions shift the moment anything is added or removed, and a text or
|
|
278
|
+
paragraph node carries no id to fall back on. `getDocument` with `outline`
|
|
279
|
+
answers with one line per node, its pointer, its `version` and an excerpt, so a
|
|
280
|
+
position can be chosen without holding the whole state. `expectedUpdatedAt`
|
|
281
|
+
still guards the document, and a `test` operation on a node's `type` guards the
|
|
282
|
+
position.
|
|
187
283
|
|
|
188
284
|
## Globals
|
|
189
285
|
|
|
@@ -192,63 +288,107 @@ tools rather than tools of its own:
|
|
|
192
288
|
|
|
193
289
|
```ts
|
|
194
290
|
mcpxPlugin({
|
|
195
|
-
collections: { pages: { read: true, write:
|
|
196
|
-
globals: { "site-settings": { read: true, write:
|
|
291
|
+
collections: { pages: { read: true, write: "draft" } },
|
|
292
|
+
globals: { "site-settings": { read: true, write: "draft" } },
|
|
197
293
|
});
|
|
198
294
|
```
|
|
199
295
|
|
|
200
|
-
Two rules follow from a global being a singleton
|
|
201
|
-
|
|
202
|
-
|
|
296
|
+
Two rules follow from a global being a singleton. JSON Schema cannot state
|
|
297
|
+
either one, so both are enforced in the handler and repeated in every affected
|
|
298
|
+
tool description:
|
|
203
299
|
|
|
204
300
|
- Pass exactly **one** of `collection` and `global`.
|
|
205
301
|
- `id` is required with `collection` and must be omitted with `global`.
|
|
206
302
|
|
|
207
303
|
Refusals name the offending argument and the slug, so one failed call teaches
|
|
208
|
-
the rule. `findDocuments` and `createDocument` stay collection-only
|
|
209
|
-
nothing to list and nothing to create when the document always exists. They
|
|
304
|
+
the rule. `findDocuments` and `createDocument` stay collection-only, since there
|
|
305
|
+
is nothing to list and nothing to create when the document always exists. They
|
|
210
306
|
reject a `global` argument by name.
|
|
211
307
|
|
|
212
308
|
Globals get their own `capabilities.globals.<name>` checkbox group, a separate
|
|
213
309
|
namespace from `capabilities.collections.<name>`, so a global may share a
|
|
214
|
-
camelCase name with a collection.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
`timestamps: false` for globals — so `expectedUpdatedAt` behaves as it does for
|
|
220
|
-
collections. The one exception is a global that has never been saved: it has no
|
|
221
|
-
`updatedAt` to compare against, so the first write must omit
|
|
310
|
+
camelCase name with a collection.
|
|
311
|
+
|
|
312
|
+
`expectedUpdatedAt` behaves as it does for collections, since Payload appends
|
|
313
|
+
`updatedAt` to every global. The exception is a global that has never been
|
|
314
|
+
saved: it has no `updatedAt` to compare against, so the first write must omit
|
|
222
315
|
`expectedUpdatedAt`, and supplying one is refused as a concurrency failure.
|
|
223
316
|
|
|
224
|
-
|
|
225
|
-
argument only appears once one is. A deployment that uses no globals sees the
|
|
226
|
-
tool schemas exactly as they were.
|
|
317
|
+
## API keys
|
|
227
318
|
|
|
228
|
-
|
|
319
|
+
Keys are created in the admin panel under MCP > API Keys. The plaintext key is
|
|
320
|
+
generated on create, stored encrypted with an HMAC index for lookup, and shown
|
|
321
|
+
to anyone who may read the key document (own keys only, by default). Each key:
|
|
229
322
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
323
|
+
- is bound to the user who created it and acts as that user. Every operation
|
|
324
|
+
runs with `req.user` set to the linked user and `overrideAccess: false`, so
|
|
325
|
+
your collection access control applies unchanged;
|
|
326
|
+
- carries one checkbox per exposed collection and operation, plus one per custom
|
|
327
|
+
tool. All checkboxes default to off. A key can never enable an operation the
|
|
328
|
+
plugin config does not expose, and keys created before a capability existed
|
|
329
|
+
stay without it. The `publish` checkbox only exists where a versioned entity
|
|
330
|
+
is configured `write: "live"`, and it counts only alongside `write`, since
|
|
331
|
+
publishing is an extension of writing.
|
|
332
|
+
|
|
333
|
+
Keys authenticate only the MCP endpoint. They are deliberately not a Payload
|
|
334
|
+
auth strategy, so a key can never authenticate the REST or GraphQL API. The
|
|
335
|
+
reverse also holds: an admin session or JWT is ignored by the MCP endpoint.
|
|
336
|
+
|
|
337
|
+
Use `apiKeys.overrideCollection` to widen access (for example, admins manage all
|
|
338
|
+
keys) or add fields.
|
|
339
|
+
|
|
340
|
+
### The "Connect a client" tab
|
|
341
|
+
|
|
342
|
+
Saved keys carry a **Connect a client** tab in the admin holding the client
|
|
343
|
+
snippets from [Usage](#usage) with their own URL and key filled in, each block
|
|
344
|
+
behind a copy button. The tab only exists once the key does, so the create form
|
|
345
|
+
stays free of it. Turn it off with `apiKeys.setupGuide: false`, which also drops
|
|
346
|
+
the tabs and restores the flat form.
|
|
347
|
+
|
|
348
|
+
The tab renders an admin component, so it has to be in the import map:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
payload generate:importmap
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Without that entry Payload logs a missing-component error and renders nothing
|
|
355
|
+
else; the rest of the plugin is unaffected. The URL comes from `serverURL` when
|
|
356
|
+
the config sets one and from the browser's origin otherwise.
|
|
357
|
+
|
|
358
|
+
## Drafts and publishing
|
|
359
|
+
|
|
360
|
+
Every MCP write lands as a draft. That is enforced on the Payload operation
|
|
361
|
+
rather than in the tool handlers, through a `beforeOperation` hook that forces
|
|
362
|
+
`draft: true` and a `beforeChange` hook that refuses any write which would still
|
|
363
|
+
not land as a draft. Both are installed on every collection and global, so a
|
|
364
|
+
custom tool writing through the same request is covered as well.
|
|
365
|
+
|
|
366
|
+
`publishDocument` is the one way through. It refuses a document that fails
|
|
367
|
+
validation, and is refused while a human holds the document open in the admin
|
|
368
|
+
panel. Publishing covers the whole document, as the admin Publish button does,
|
|
369
|
+
but Payload only validates the locale the publish runs in, so a required field
|
|
370
|
+
left empty in another locale goes live empty. That is Payload's behaviour, not
|
|
371
|
+
something this plugin adds. There is no unpublish tool: reverting a published
|
|
372
|
+
document to a draft stays a human action.
|
|
237
373
|
|
|
238
374
|
Publish blockers are advisory. Payload skips validation on draft saves (unless
|
|
239
375
|
`versions.drafts.validate` is set), so after every write the plugin re-runs
|
|
240
|
-
Payload's own field validation over the saved draft and returns the failures
|
|
241
|
-
|
|
242
|
-
checklist of what remains.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
376
|
+
Payload's own field validation over the saved draft and returns the failures as
|
|
377
|
+
`publishBlockers` with paths and labels. The write stands; the client gets a
|
|
378
|
+
checklist of what remains. Collections with `versions.drafts.validate: true`
|
|
379
|
+
refuse invalid drafts outright, and those failures come back as
|
|
380
|
+
`validationErrors` instead. Both carry pointers.
|
|
381
|
+
|
|
382
|
+
`publishBlockersUnavailable` marks a check that could not complete, which is a
|
|
383
|
+
different answer from a document with nothing wrong with it. Writes also report
|
|
384
|
+
`notApplied`: pointers whose value Payload kept unchanged, which happens when
|
|
385
|
+
field-level access denies the update.
|
|
386
|
+
|
|
387
|
+
Three limits apply to that check. Only the written locale is validated. Field
|
|
388
|
+
`beforeChange` hooks run again during it, so they must be pure. And it runs
|
|
389
|
+
privileged, so blocker paths and messages may name fields the key's user cannot
|
|
390
|
+
read, though values are never included. `validateDocument` runs the same
|
|
391
|
+
traversal without saving anything, which is why it carries no `readOnlyHint`.
|
|
252
392
|
|
|
253
393
|
## Custom tools
|
|
254
394
|
|
|
@@ -276,57 +416,54 @@ const publishQueue = defineMcpxTool({
|
|
|
276
416
|
});
|
|
277
417
|
```
|
|
278
418
|
|
|
279
|
-
|
|
419
|
+
Custom tools take the same route as the builtins: one `McpxTool` shape, one
|
|
420
|
+
registration loop. Each gets its own checkbox on every API key, default off.
|
|
280
421
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
422
|
+
`handler` receives `scope` alongside `args`, `req` and `extra`. The scope
|
|
423
|
+
carries what the key may touch (`readable`, `writable`, `publishable`,
|
|
424
|
+
`readableGlobals`, `writableGlobals`, `publishableGlobals`), the configured
|
|
425
|
+
locales, the limits in force and the exposed collections and globals. `req` is
|
|
426
|
+
shorthand for `scope.req`.
|
|
284
427
|
|
|
285
|
-
|
|
428
|
+
`inputSchema` may be a function of that scope instead of a fixed shape, which is
|
|
429
|
+
how a tool narrows an enum to what the key may read:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
import { defineMcpxTool } from "@abinnovision/payloadcms-mcpx";
|
|
433
|
+
import { z } from "zod";
|
|
434
|
+
|
|
435
|
+
const whichCollection = defineMcpxTool({
|
|
436
|
+
name: "whichCollection",
|
|
437
|
+
description: "Echoes back one of the collections this key may read.",
|
|
438
|
+
isEnabled: (scope) =>
|
|
439
|
+
scope.capabilities.tools["whichCollection"] === true &&
|
|
440
|
+
scope.readable.length > 0,
|
|
441
|
+
inputSchema: (scope) => ({
|
|
442
|
+
collection: z.enum(scope.readable as [string, ...string[]]),
|
|
443
|
+
}),
|
|
444
|
+
handler: ({ args }) => ({
|
|
445
|
+
content: [{ type: "text", text: args.collection }],
|
|
446
|
+
}),
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
`defineMcpxTool` infers the handler's arguments from the input schema either
|
|
451
|
+
way, so `args` above is `{ collection: string }` without being told. Inference
|
|
452
|
+
reaches as far as the shape's static type, so a helper returning `z.ZodRawShape`
|
|
453
|
+
leaves `args` as `Record<string, unknown>`. Where that happens, state the
|
|
454
|
+
arguments as a type argument: `defineMcpxTool<Args>({ ... })`.
|
|
455
|
+
|
|
456
|
+
`isEnabled` decides whether the tool is registered for this key at all: a tool
|
|
457
|
+
that is not enabled never appears in `tools/list`. It defaults to the tool's own
|
|
458
|
+
checkbox, and defining it **replaces** that check, so restate
|
|
459
|
+
`scope.capabilities.tools[name]` when you still want it, as above.
|
|
460
|
+
|
|
461
|
+
Every input schema is registered strictly, custom tools included: an unknown
|
|
462
|
+
argument is rejected by name rather than stripped before the handler runs.
|
|
286
463
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
| `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
|
|
291
|
-
| `collections.<slug>.write` | `false` | Expose `patchDocument`, `createDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
|
|
292
|
-
| `collections.<slug>.allowLiveWrites` | `false` | Permit writes to a collection without drafts (they land live). |
|
|
293
|
-
| `globals` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
|
|
294
|
-
| `globals.<slug>.read` | `true` | Expose `describeSchema`, `getDocument`. |
|
|
295
|
-
| `globals.<slug>.write` | `false` | Expose `patchDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
|
|
296
|
-
| `globals.<slug>.allowLiveWrites` | `false` | Permit writes to a global without drafts (they land live). |
|
|
297
|
-
| `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
|
|
298
|
-
| `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
|
|
299
|
-
| `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
|
|
300
|
-
| `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
|
|
301
|
-
| `limits.maxLimit` | `25` | Upper bound for `findDocuments.limit`. |
|
|
302
|
-
| `limits.maxDepth` | `1` | Upper bound for `depth` on reads. |
|
|
303
|
-
| `tools` | `[]` | Custom tools. |
|
|
304
|
-
| `auth.resolve` | none | Replace or wrap the default key resolution. |
|
|
305
|
-
| `serverInfo` | package name and version | Reported to MCP clients. |
|
|
306
|
-
|
|
307
|
-
Misconfiguration (unknown slugs, write on a collection without drafts, upload
|
|
308
|
-
collections exposed for write, tool name collisions) fails at startup with
|
|
309
|
-
`InvalidConfiguration`. Auth collections cannot be exposed at all, read
|
|
310
|
-
included: their documents carry credentials, such as the decrypted Payload API
|
|
311
|
-
key of every user.
|
|
312
|
-
|
|
313
|
-
## Security notes
|
|
314
|
-
|
|
315
|
-
- Keys are stored encrypted; lookup is by HMAC-SHA256 index derived from
|
|
316
|
-
`payload.secret`, the same scheme Payload uses for its own API keys.
|
|
317
|
-
- The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
|
|
318
|
-
ignored. Keys cannot authenticate REST or GraphQL.
|
|
319
|
-
- Every operation runs under the linked user with `overrideAccess: false`.
|
|
320
|
-
- Not covered in v1: `delete` (no tool exists and none is generated), uploads.
|
|
321
|
-
Custom tools are trusted code and can do what the linked user may.
|
|
322
|
-
|
|
323
|
-
## Non-goals of v1 / roadmap
|
|
324
|
-
|
|
325
|
-
Deletes, uploads, markdown authoring for rich text, addressing a rich text node
|
|
326
|
-
by position in a patch (an editor state is written whole), schemas for `upload`
|
|
327
|
-
node fields, row addressing by id instead of index, cross-locale publish
|
|
328
|
-
blockers, pagination of `describeSchema` with `expand`, and a handler-level
|
|
329
|
-
timeout are all deliberate omissions for now.
|
|
464
|
+
`jsonResult` and `errorResult` are exported so a custom tool can return results
|
|
465
|
+
shaped like a builtin's. `isMcpxRequest(req)` lets your own hooks tell an
|
|
466
|
+
MCP-originated write from any other.
|
|
330
467
|
|
|
331
468
|
## License
|
|
332
469
|
|