@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.8

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.
Files changed (69) hide show
  1. package/README.md +140 -470
  2. package/dist/{client → admin}/capability-matrix.d.mts +3 -4
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +1 -1
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +6 -6
  8. package/dist/api-keys/capability-matrix.mjs +15 -85
  9. package/dist/api-keys/fields.mjs +15 -24
  10. package/dist/auth/resolve.mjs +23 -5
  11. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
  12. package/dist/capabilities.mjs +34 -42
  13. package/dist/define-tool.d.mts +19 -0
  14. package/dist/{types.mjs → define-tool.mjs} +1 -1
  15. package/dist/endpoint/errors.mjs +5 -6
  16. package/dist/endpoint/handler.mjs +49 -19
  17. package/dist/endpoint/server.mjs +65 -19
  18. package/dist/entity.d.mts +9 -0
  19. package/dist/guards.mjs +5 -0
  20. package/dist/i18n.mjs +8 -8
  21. package/dist/index.d.mts +5 -2
  22. package/dist/index.mjs +3 -2
  23. package/dist/options.mjs +47 -45
  24. package/dist/plugin.mjs +0 -1
  25. package/dist/request.d.mts +2 -3
  26. package/dist/request.mjs +2 -3
  27. package/dist/result.d.mts +2 -3
  28. package/dist/result.mjs +3 -3
  29. package/dist/schema/admin-hidden.mjs +91 -0
  30. package/dist/schema/describe.mjs +17 -33
  31. package/dist/schema/errors.mjs +8 -0
  32. package/dist/schema/index.mjs +5 -2
  33. package/dist/schema/lexical-pointer.mjs +18 -20
  34. package/dist/schema/lexical.mjs +39 -92
  35. package/dist/schema/outline.mjs +5 -15
  36. package/dist/schema/path.mjs +22 -0
  37. package/dist/schema/pointer.mjs +18 -25
  38. package/dist/schema/shape.mjs +25 -71
  39. package/dist/schema/walk.mjs +35 -45
  40. package/dist/tools/builtin.mjs +4 -4
  41. package/dist/tools/create-document.mjs +26 -23
  42. package/dist/tools/describe-schema.mjs +38 -34
  43. package/dist/tools/document.mjs +84 -0
  44. package/dist/tools/find-documents.mjs +32 -19
  45. package/dist/tools/find-versions.mjs +22 -18
  46. package/dist/tools/get-document.mjs +39 -32
  47. package/dist/tools/list-capabilities.mjs +23 -16
  48. package/dist/tools/patch-document.mjs +74 -50
  49. package/dist/tools/publish-document.mjs +17 -28
  50. package/dist/tools/query-paths.mjs +55 -0
  51. package/dist/tools/read-request.d.mts +10 -0
  52. package/dist/tools/read-request.mjs +43 -0
  53. package/dist/tools/shared.mjs +58 -99
  54. package/dist/tools/validate-document.mjs +16 -29
  55. package/dist/tools/versions.mjs +25 -17
  56. package/dist/types.d.mts +47 -56
  57. package/dist/write/draft-guard.mjs +17 -37
  58. package/dist/write/patch.mjs +27 -183
  59. package/dist/write/publish-blockers.mjs +7 -14
  60. package/dist/write/publish-intent.mjs +4 -4
  61. package/dist/write/row-ids.mjs +44 -0
  62. package/dist/write/transaction.mjs +5 -8
  63. package/dist/write/write-data.mjs +64 -0
  64. package/package.json +5 -5
  65. package/dist/client/capability-matrix.mjs +0 -349
  66. package/dist/endpoint/index.mjs +0 -4
  67. package/dist/tools/target.mjs +0 -45
  68. /package/dist/{client → admin}/index.d.mts +0 -0
  69. /package/dist/{client → admin}/index.mjs +0 -0
package/README.md CHANGED
@@ -1,40 +1,39 @@
1
1
  # @abinnovision/payloadcms-mcpx
2
2
 
3
- ![A content model of any size funnels through eight fixed tools out to one client](https://raw.githubusercontent.com/abinnovision/payloadcms-commons/main/packages/mcpx/assets/header.png)
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.
3
+ ![A content model of any size funnels through nine fixed tools out to one client](https://raw.githubusercontent.com/abinnovision/payloadcms-commons/main/packages/mcpx/assets/header.png)
4
+
5
+ A [Payload CMS](https://payloadcms.com/) plugin that serves an MCP (Model Context Protocol) server
6
+ on one `POST` endpoint. Clients authenticate with API keys that users create in the admin panel.
7
+
8
+ The server has nine tools plus any you add, and that number does not grow with the content model.
9
+ A client reads the shape of one collection, block or rich text node at a time through
10
+ `describeSchema`, and writes with RFC 6902 JSON Patch operations that the plugin checks against
11
+ the real config before anything is saved. Writes land as drafts unless the config allows more,
12
+ and each write reports what still blocks publishing.
13
+ [`docs/concepts.md`](./docs/concepts.md) explains the model, and
14
+ [`docs/security.md`](./docs/security.md) states what a key can reach.
19
15
 
20
16
  ## Install
21
17
 
22
- ```bash
18
+ ```sh
23
19
  yarn add @abinnovision/payloadcms-mcpx
24
20
  ```
25
21
 
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
- - The package is published as ESM only, matching Payload itself.
22
+ | Peer | Range | Needed for |
23
+ | ---------------- | ------------- | -------------------------------------------------------------- |
24
+ | `payload` | `>=3.88.0 <4` | everything (required) |
25
+ | `@payloadcms/ui` | `>=3.88.0 <4` | the admin components: capability matrix and "Connect a client" |
26
+ | `react` | `^19` | the admin components |
31
27
 
32
- ## Usage
28
+ `@payloadcms/ui` and `react` are optional. A deployment that does not serve the admin panel can
29
+ leave them out. The package is ESM only, like Payload.
33
30
 
34
- Name the collections and globals the plugin may reach. Nothing outside this list
35
- is exposed:
31
+ ## Setup
32
+
33
+ List the collections and globals the plugin may reach. Anything not listed is not exposed:
36
34
 
37
35
  ```ts
36
+ // payload.config.ts
38
37
  import { mcpxPlugin } from "@abinnovision/payloadcms-mcpx";
39
38
  import { buildConfig } from "payload";
40
39
 
@@ -42,15 +41,15 @@ export default buildConfig({
42
41
  // ...
43
42
  plugins: [
44
43
  mcpxPlugin({
44
+ // pages and posts have versions.drafts enabled
45
45
  collections: {
46
- pages: { read: true, write: "live" }, // may be published through MCP
47
- posts: { read: true, write: "draft" }, // drafts only
48
- tags: true, // shorthand for { read: true }
46
+ pages: true, // everything the collection supports
47
+ posts: { publish: false }, // everything except publishing
48
+ tags: { write: false }, // read only
49
49
  },
50
50
  globals: {
51
- "site-settings": { read: true, write: "live" },
51
+ "site-settings": true,
52
52
  },
53
- limits: { maxLimit: 25, maxDepth: 1 },
54
53
  }),
55
54
  ],
56
55
  });
@@ -58,460 +57,131 @@ export default buildConfig({
58
57
 
59
58
  The plugin adds:
60
59
 
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,
65
- - a draft guard on every collection and global, so any write carrying the MCP
66
- request marker lands as a draft, custom tools included.
67
-
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.
60
+ - `POST /api/mcpx`, an MCP endpoint over streamable HTTP. It is stateless and answers with JSON.
61
+ - An `mcpx-api-keys` collection in the admin group "MCP", holding the keys.
62
+ - A draft guard on every collection and global that turns MCP writes into draft saves.
72
63
 
73
- Then point a client at the endpoint, passing the key as a bearer token:
64
+ The key form uses two admin components, so regenerate the import map:
74
65
 
75
- ```bash
76
- npx @modelcontextprotocol/inspector
77
- # transport: Streamable HTTP, URL: http://localhost:3000/api/mcpx
78
- # header: Authorization: Bearer <key>
66
+ ```sh
67
+ payload generate:importmap
79
68
  ```
80
69
 
81
- Claude Code:
70
+ Without it, no key can be granted anything from the admin panel. The endpoint is unaffected.
71
+
72
+ Then create a key in the admin panel under MCP > API Keys, tick what it may do, save, and copy
73
+ the key from the saved document. Every checkbox starts unticked, so a new key can only call
74
+ `listCapabilities`.
75
+
76
+ Connect a client with the key as a bearer token. Claude Code:
82
77
 
83
- ```bash
78
+ ```sh
84
79
  claude mcp add --transport http payload http://localhost:3000/api/mcpx \
85
- --header "Authorization: Bearer <key>"
80
+ --header "Authorization: Bearer <key>"
86
81
  ```
87
82
 
88
- Claude Desktop has no direct HTTP header support, so it goes through
89
- `mcp-remote`:
90
-
91
- ```json
92
- {
93
- "mcpServers": {
94
- "payload": {
95
- "command": "npx",
96
- "args": [
97
- "-y",
98
- "mcp-remote",
99
- "http://localhost:3000/api/mcpx",
100
- "--header",
101
- "Authorization: Bearer <key>"
102
- ]
103
- }
104
- }
105
- }
106
- ```
83
+ Claude Desktop and the MCP Inspector are covered in
84
+ [`docs/integration.md`](./docs/integration.md#connecting-other-clients). Saved keys also have a
85
+ "Connect a client" tab with ready-to-paste snippets.
107
86
 
108
87
  ## Options
109
88
 
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.
89
+ | Option | Default | Description |
90
+ | ---------------------------- | ----------------------------------------- | ------------------------------------------------------------------- |
91
+ | `collections` | required | Collections to expose. `true` means `{}`, everything it supports. |
92
+ | `collections.<slug>.read` | `true` | Expose the read tools for this collection. |
93
+ | `collections.<slug>.write` | `true` | Expose the write tools. Without drafts, a write goes live. |
94
+ | `collections.<slug>.publish` | `true` with drafts | Expose `publishDocument`. Needs `versions.drafts` and `write`. |
95
+ | `globals` | `{}` | Globals to expose, with the same three options. |
96
+ | `userCollection` | `config.admin.user`, then `users` | Auth collection whose users the keys act as. |
97
+ | `apiKeys.slug` | `mcpx-api-keys` | Slug of the key collection. |
98
+ | `apiKeys.setupGuide` | `true` | Add the "Connect a client" tab to saved keys. |
99
+ | `apiKeys.overrideCollection` | none | Function that receives the key collection and returns it. |
100
+ | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
101
+ | `limits.maxLimit` | `25` | Highest `limit` a client may pass to a list tool. |
102
+ | `limits.maxDepth` | `1` | Highest `depth` a client may pass to a read tool. |
103
+ | `tools` | `[]` | Custom tools, see [`docs/custom-tools.md`](./docs/custom-tools.md). |
104
+ | `auth.resolve` | none | Replace or wrap the key lookup. |
105
+ | `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients. |
106
+
107
+ The config only takes capabilities away: `true` exposes everything the entity supports, and a key's
108
+ checkboxes decide what each key may do. `publish: false` keeps MCP writes as drafts. On an entity
109
+ without drafts, a write changes the live document. Version history has no checkbox of its own: it
110
+ follows the key's read wherever the entity keeps Payload `versions`. Read
111
+ [version history](./docs/security.md#version-history) first.
112
+ Mistakes in the options fail at startup. [`docs/integration.md`](./docs/integration.md) lists
113
+ every check.
165
114
 
166
115
  ## Tools
167
116
 
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, an old version, or a diff. | `collection` + `id` \| `global`, `path?`, `depth?`, `locale?`, `draft?`, `outline?`, `versionId?`, `diffFrom?` |
179
- | `findVersions` | Version history of one document or global, metadata only. | `collection` + `id` \| `global`, `limit?`, `page?`, `status?`, `locale?` |
180
- | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
181
- | `createDocument` | Create a draft from a minimal seed. Not for upload collections. | `collection`, `locale`, `data` |
182
- | `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
183
- | `publishDocument` | Publish the current draft. | `collection` + `id` \| `global`, `expectedUpdatedAt?` |
184
-
185
- ### Paths and pointers
186
-
187
- Every path this plugin accepts or reports is a JSON Pointer. A schema path and a
188
- pointer into a document differ only in what stands in an element position: a
189
- schema path writes `*` for an array element and names a block by its slug, where
190
- a pointer carries a 0-based index. So `/items/*/title` is written at
191
- `/items/0/title`, and `/layout/sections/hero` at `/layout/sections/0`.
192
-
193
- Inside a rich text field that substitution does not apply, because an editor
194
- state is a tree rather than a list per type. A path there names the node type,
195
- and a block node its slug. A pointer enters the state at `root` and walks
196
- `children` by an index counted over every child at that level, with the node's
197
- own fields under `fields`. So the path `/content/block/practice-note/variant` is
198
- written at the pointer `/content/root/children/7/fields/variant`, and only the
199
- stored state says which index that is. `getDocument` with `outline` answers
200
- that.
201
-
202
- ### Reading the schema
203
-
204
- - Paths stop at blocks fields, which list the block slugs they accept. Every
205
- node carries `next`, the ready-to-use paths for those blocks
206
- (`/layout/sections/sectionWrapper`), so pass an entry of `next` as a `paths`
207
- element to descend. A block is described as it exists at that position.
208
- - A field marked `admin.hidden` is neither described nor writable. Payload keeps
209
- such a field out of the admin panel only, where this plugin keeps it from the
210
- client as well.
211
- - Constraints a field declares travel with it: `minRows` and `maxRows` on arrays
212
- and blocks fields, `maxLength` and `minLength` on text, `min` and `max` on
213
- numbers. An array is described in its own right, so the `*` in `/items/*/title`
214
- has something to read. A group or named tab is described only when it declares
215
- a description or a constraint of its own.
216
- - Field and collection `admin.description` values reach `describeSchema` and
217
- `listCapabilities`, so intent written for the admin panel reaches the client. A
218
- locale-keyed record resolves to one string for the request's language, falling
219
- back to the deployment's fallback language and then to the record's first
220
- entry. Functions and components are dropped.
221
-
222
- ### Patching
223
-
224
- - Adding a block requires `blockType` on the value. Append with `/-`.
225
- - Clearing is `replace` with `null`. A list is emptied with `[]` and refuses
226
- `null`. `remove` is only valid on list elements, because Payload keeps fields
227
- absent from a write.
228
- - Nothing in a patch batch is applied unless every operation validates first.
229
- - Pass the `updatedAt` you read as `expectedUpdatedAt` so a concurrent edit is
230
- refused instead of overwritten.
231
- - Fields Payload maintains (`id`, `_status`, `createdAt`, `updatedAt`,
232
- `deletedAt`) are never listed and never writable. `readOnly` fields are listed
233
- but refused on write.
234
-
235
- ### Rich text
236
-
237
- A `richText` field lists the Lexical node types it accepts in `nodes`, and
238
- `next` carries a path for every node type that holds fields of its own:
239
- `/content/link` for a link node, `/content/block/callout` and
240
- `/content/inlineBlock/badge` for the block nodes. Descending returns the real
241
- field list. `upload` nodes are the exception, since their fields depend on the
242
- collection the node points at, so they are not addressable.
243
-
244
- A field also reports `nodeOptions`, the node properties its editor narrows. An
245
- editor built with `HeadingFeature({ enabledHeadingSizes: ["h4"] })` answers
246
- `{ "heading": { "tag": ["h4"] } }`, and a write carrying any other heading tag is
247
- refused. Lexical stores whatever tag it is given, so this is the only place the
248
- restriction is checked.
249
-
250
- A node must be written the way Lexical serializes it, carrying the values
251
- Lexical would have written. Payload does not check that on write, so this plugin
252
- does, and the refusal names the property and what belongs there. A
253
- `describeSchema` response that reached a `richText` field ends with a
254
- `nodeProperties` entry stating what each node type has to carry, keyed by node
255
- type and in the same words the refusal uses. Its `text` entry reads:
256
-
257
- ```json
258
- {
259
- "detail": "a number",
260
- "format": "a number",
261
- "mode": "a string",
262
- "style": "a string",
263
- "text": "a string",
264
- "type": "a string",
265
- "version": "a number"
266
- }
267
- ```
268
-
269
- A field's value is addressable, so a small edit does not have to rewrite the
270
- whole state. `/content/root/children/2` is a node,
271
- `/content/root/children/2/tag` one of its properties, and
272
- `/content/root/children/2/fields/url` a field the node carries. The root and a
273
- node's `type` cannot be replaced on their own, and a node property cannot be
274
- removed. A state whose root holds nothing is refused however it is written,
275
- since Lexical reads it as empty and throws rather than rendering it; an empty
276
- field is stored as null instead.
277
-
278
- Node positions shift the moment anything is added or removed, and a text or
279
- paragraph node carries no id to fall back on. `getDocument` with `outline`
280
- answers with one line per node, its pointer, its `version` and an excerpt, so a
281
- position can be chosen without holding the whole state. `expectedUpdatedAt`
282
- still guards the document, and a `test` operation on a node's `type` guards the
283
- position.
284
-
285
- ## Globals
286
-
287
- A global is exposed the same way a collection is, and reached through the same
288
- tools rather than tools of its own:
289
-
290
- ```ts
291
- mcpxPlugin({
292
- collections: { pages: { read: true, write: "draft" } },
293
- globals: { "site-settings": { read: true, write: "draft" } },
294
- });
295
- ```
296
-
297
- Two rules follow from a global being a singleton. JSON Schema cannot state
298
- either one, so both are enforced in the handler and repeated in every affected
299
- tool description:
300
-
301
- - Pass exactly **one** of `collection` and `global`.
302
- - `id` is required with `collection` and must be omitted with `global`.
303
-
304
- Refusals name the offending argument and the slug, so one failed call teaches
305
- the rule. `findDocuments` and `createDocument` stay collection-only, since there
306
- is nothing to list and nothing to create when the document always exists. They
307
- reject a `global` argument by name.
308
-
309
- Globals get their own `capabilities.globals.<name>` checkbox group, a separate
310
- namespace from `capabilities.collections.<name>`, so a global may share a
311
- camelCase name with a collection.
312
-
313
- `expectedUpdatedAt` behaves as it does for collections, since Payload appends
314
- `updatedAt` to every global. The exception is a global that has never been
315
- saved: it has no `updatedAt` to compare against, so the first write must omit
316
- `expectedUpdatedAt`, and supplying one is refused as a concurrency failure.
317
-
318
- ## API keys
319
-
320
- Keys are created in the admin panel under MCP > API Keys. The plaintext key is
321
- generated on create, stored encrypted with an HMAC index for lookup, and shown
322
- to anyone who may read the key document (own keys only, by default). Each key:
323
-
324
- - is bound to the user who created it and acts as that user. Every operation
325
- runs with `req.user` set to the linked user and `overrideAccess: false`, so
326
- your collection access control applies unchanged;
327
- - carries one checkbox per exposed collection and operation, plus one per custom
328
- tool. All checkboxes default to off. A key can never enable an operation the
329
- plugin config does not expose, and keys created before a capability existed
330
- stay without it. The `publish` checkbox only exists where a versioned entity
331
- is configured `write: "live"`, and it counts only alongside `write`, since
332
- publishing is an extension of writing.
333
-
334
- ### The capabilities matrix
335
-
336
- The checkboxes render as one table per namespace: a row per collection, global
337
- or tool, a column per operation, and a bulk toggle in each column header that
338
- grants or clears the whole column at once. Clicking a row's name does the same
339
- for that entity.
340
-
341
- A cell the plugin config does not expose shows a dash instead of an empty box,
342
- so a `write: false` collection reads as a refusal by config rather than a
343
- capability someone forgot to tick. A column no row exposes is left out
344
- altogether. Ticking `publish` ticks `write` with it, and clearing `write` clears
345
- `publish`, because a publish without a write resolves to nothing.
346
-
347
- Each operation is explained on its column header rather than beside every
348
- checkbox, so the same sentence is never repeated down the table.
349
-
350
- The matrix renders an admin component, so it has to be in the import map; see
351
- [Admin components](#admin-components).
352
-
353
- Keys authenticate only the MCP endpoint. They are deliberately not a Payload
354
- auth strategy, so a key can never authenticate the REST or GraphQL API. The
355
- reverse also holds: an admin session or JWT is ignored by the MCP endpoint.
356
-
357
- Use `apiKeys.overrideCollection` to widen access (for example, admins manage all
358
- keys) or add fields.
359
-
360
- ### The "Connect a client" tab
361
-
362
- Saved keys carry a **Connect a client** tab in the admin holding the client
363
- snippets from [Usage](#usage) with their own URL and key filled in, each block
364
- behind a copy button. The tab only exists once the key does, so the create form
365
- stays free of it. Turn it off with `apiKeys.setupGuide: false`, which also drops
366
- the tabs and restores the flat form.
367
-
368
- The URL comes from `serverURL` when the config sets one and from the browser's
369
- origin otherwise.
370
-
371
- ### Admin components
372
-
373
- The key form renders two components of its own, the capabilities matrix and the
374
- setup guide, so both have to be in the import map:
375
-
376
- ```bash
377
- payload generate:importmap
378
- ```
379
-
380
- Without those entries Payload logs a missing-component error and renders nothing
381
- in their place. For the setup guide that costs a tab; for the matrix it costs
382
- the whole capabilities editor, leaving no way to grant a key anything from the
383
- admin. The rest of the plugin is unaffected either way.
384
-
385
- ## Drafts and publishing
386
-
387
- Every MCP write lands as a draft. That is enforced on the Payload operation
388
- rather than in the tool handlers, through a `beforeOperation` hook that forces
389
- `draft: true` and a `beforeChange` hook that refuses any write which would still
390
- not land as a draft. Both are installed on every collection and global, so a
391
- custom tool writing through the same request is covered as well.
392
-
393
- `publishDocument` is the one way through. It refuses a document that fails
394
- validation, and is refused while a human holds the document open in the admin
395
- panel. Publishing covers the whole document, as the admin Publish button does,
396
- but Payload only validates the locale the publish runs in, so a required field
397
- left empty in another locale goes live empty. That is Payload's behaviour, not
398
- something this plugin adds. There is no unpublish tool: reverting a published
399
- document to a draft stays a human action.
400
-
401
- Publish blockers are advisory. Payload skips validation on draft saves (unless
402
- `versions.drafts.validate` is set), so after every write the plugin re-runs
403
- Payload's own field validation over the saved draft and returns the failures as
404
- `publishBlockers` with paths and labels. The write stands; the client gets a
405
- checklist of what remains. Collections with `versions.drafts.validate: true`
406
- refuse invalid drafts outright, and those failures come back as
407
- `validationErrors` instead. Both carry pointers.
408
-
409
- `publishBlockersUnavailable` marks a check that could not complete, which is a
410
- different answer from a document with nothing wrong with it. Writes also report
411
- `notApplied`: pointers whose value Payload kept unchanged, which happens when
412
- field-level access denies the update.
413
-
414
- Three limits apply to that check. Only the written locale is validated. Field
415
- `beforeChange` hooks run again during it, so they must be pure. And it runs
416
- privileged, so blocker paths and messages may name fields the key's user cannot
417
- read, though values are never included. `validateDocument` runs the same
418
- traversal without saving anything, which is why it carries no `readOnlyHint`.
419
-
420
- ## Version history
421
-
422
- On a collection or global with `versions` (with or without drafts),
423
- `findVersions` lists the history newest first: `versionId`, timestamps,
424
- `status`, `latest` and `autosave`, but no bodies. It is a read, so it follows
425
- the `read` capability. The document's own `read` access is checked first, then
426
- Payload's `readVersions`.
427
-
428
- `getDocument` takes `versionId` to read one of those versions in place of the
429
- document, with `path`, `outline`, `locale` and `depth` working as usual. With
430
- `diffFrom`, a `versionId` or `"published"` (the newest version with published
431
- status, in any locale), it returns `{ from, to, patch }`: the RFC 6902
432
- operations that turn `diffFrom` into the document read (the current draft, or
433
- `versionId` when given). The ops use the pointers `patchDocument` takes, `path`
434
- limits them to a subtree, and `id`, timestamps and `_status` are left out. Arrays diff by position, so a reordered block shows up as replaces.
435
-
436
- There is no restore tool. To revert, pass the old version as `versionId` and the
437
- latest one as `diffFrom`, then apply the patch with `patchDocument`. The revert
438
- lands as a draft like any other write, with `expectedUpdatedAt` and publish
439
- blockers in force.
440
-
441
- ## Custom tools
442
-
443
- ```ts
444
- import { defineMcpxTool } from "@abinnovision/payloadcms-mcpx";
445
- import { z } from "zod";
446
-
447
- const publishQueue = defineMcpxTool({
448
- name: "queueForReview",
449
- description: "Marks a page as ready for editorial review.",
450
- inputSchema: { id: z.string() },
451
- handler: async ({ args, req }) => {
452
- // req.user is the key's linked user, req.context.mcpx carries the
453
- // key id and capabilities. Writes through payload.update({ req })
454
- // land as drafts like every other MCP write.
455
- await req.payload.update({
456
- collection: "pages",
457
- id: args.id,
458
- data: { reviewRequested: true },
459
- overrideAccess: false,
460
- req,
461
- });
462
- return { content: [{ type: "text", text: "queued" }] };
463
- },
464
- });
465
- ```
466
-
467
- Custom tools take the same route as the builtins: one `McpxTool` shape, one
468
- registration loop. Each gets its own checkbox on every API key, default off.
469
-
470
- `handler` receives `scope` alongside `args`, `req` and `extra`. The scope
471
- carries what the key may touch (`readable`, `writable`, `publishable`,
472
- `readableGlobals`, `writableGlobals`, `publishableGlobals`), the configured
473
- locales, the limits in force and the exposed collections and globals. `req` is
474
- shorthand for `scope.req`.
475
-
476
- `inputSchema` may be a function of that scope instead of a fixed shape, which is
477
- how a tool narrows an enum to what the key may read:
478
-
479
- ```ts
480
- import { defineMcpxTool } from "@abinnovision/payloadcms-mcpx";
481
- import { z } from "zod";
482
-
483
- const whichCollection = defineMcpxTool({
484
- name: "whichCollection",
485
- description: "Echoes back one of the collections this key may read.",
486
- isEnabled: (scope) =>
487
- scope.capabilities.tools["whichCollection"] === true &&
488
- scope.readable.length > 0,
489
- inputSchema: (scope) => ({
490
- collection: z.enum(scope.readable as [string, ...string[]]),
491
- }),
492
- handler: ({ args }) => ({
493
- content: [{ type: "text", text: args.collection }],
494
- }),
495
- });
496
- ```
497
-
498
- `defineMcpxTool` infers the handler's arguments from the input schema either
499
- way, so `args` above is `{ collection: string }` without being told. Inference
500
- reaches as far as the shape's static type, so a helper returning `z.ZodRawShape`
501
- leaves `args` as `Record<string, unknown>`. Where that happens, state the
502
- arguments as a type argument: `defineMcpxTool<Args>({ ... })`.
503
-
504
- `isEnabled` decides whether the tool is registered for this key at all: a tool
505
- that is not enabled never appears in `tools/list`. It defaults to the tool's own
506
- checkbox, and defining it **replaces** that check, so restate
507
- `scope.capabilities.tools[name]` when you still want it, as above.
508
-
509
- Every input schema is registered strictly, custom tools included: an unknown
510
- argument is rejected by name rather than stripped before the handler runs.
511
-
512
- `jsonResult` and `errorResult` are exported so a custom tool can return results
513
- shaped like a builtin's. `isMcpxRequest(req)` lets your own hooks tell an
514
- MCP-originated write from any other.
117
+ | Tool | What it does | Needs |
118
+ | ------------------ | ------------------------------------------------------------------ | ------------------ |
119
+ | `listCapabilities` | Lists what this key may do. Clients call it first. | any key |
120
+ | `describeSchema` | Describes the fields at one schema path, with the paths below it. | `read` |
121
+ | `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read` |
122
+ | `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read` |
123
+ | `findVersions` | Lists the version history of a document or global, without bodies. | `read` |
124
+ | `patchDocument` | Applies JSON Patch operations to the current draft. | `write` |
125
+ | `createDocument` | Creates a draft from a seed. Not for upload collections. | `write` |
126
+ | `validateDocument` | Lists what blocks publishing, without saving. | `write` |
127
+ | `publishDocument` | Publishes the current draft. | `write`, `publish` |
128
+
129
+ Collections and globals use the same tools. A tool that addresses one document takes either
130
+ `collection` and `id`, or `global` alone. [`docs/concepts.md`](./docs/concepts.md#tools) lists
131
+ every argument.
132
+
133
+ ## Capabilities
134
+
135
+ A key can do something only when both the config and the key allow it.
136
+
137
+ The plugin config sets the upper bound: which collections and globals are exposed, and whether
138
+ each one is readable, writable and publishable. A key cannot go past it.
139
+
140
+ Each key carries one checkbox per exposed entity and operation (`read`, `write`, `publish`), and
141
+ one per custom tool. The admin panel shows them as a matrix with a row per entity and a column per
142
+ operation. A cell the config does not expose shows a dash. `publish` exists only where the config
143
+ exposes `write` and `publish` on an entity with drafts, and counts only when `write` is ticked as
144
+ well. A write checkbox on an entity without drafts is marked "Writes go live immediately."
145
+
146
+ `tools/list` follows the key. A tool the key cannot use is not listed, and each `collection` and
147
+ `global` argument lists only the slugs the key may use with that tool. A checkbox added by a later
148
+ config change starts unticked on existing keys.
149
+
150
+ Any user in the user collection can create a key and tick every capability the config exposes.
151
+ To restrict that, use `apiKeys.overrideCollection` as shown in
152
+ [`docs/security.md`](./docs/security.md#who-can-create-keys).
153
+
154
+ ## Security
155
+
156
+ - Only an API key authenticates. Admin sessions and JWTs are ignored, and a key does not work on
157
+ the REST or GraphQL API.
158
+ - A key acts as its user, and Payload access control applies to what its tools read and write.
159
+ - Relations are populated only into collections the key can read.
160
+ - The read tools leave out fields with `admin.hidden`. `findDocuments` refuses a `where` or `sort`
161
+ that names one, or that goes through a relation into a collection the key cannot read.
162
+ - After a write, the tools re-read the document with full access to report publish blockers.
163
+ Blocker messages can name fields the user cannot read.
164
+ - The request body is limited to 4 MB, a JSON-RPC batch to 10 messages, `patchDocument` to 500
165
+ operations and `describeSchema` to 400 paths.
166
+ - Custom tools are trusted code and must apply access control themselves.
167
+
168
+ Open limitations: a filter through a relation into a readable collection ignores that collection's
169
+ row-level `read` rule, rich text is not walked for hidden fields, and `admin.hidden` on a row, tab
170
+ or collapsible does not reach the fields inside it. Details:
171
+ [`docs/security.md`](./docs/security.md).
172
+
173
+ ## Documentation
174
+
175
+ Upgrading from 1.x: [Upgrading to 2.0](./docs/integration.md#upgrading-to-20).
176
+
177
+ - [`docs/concepts.md`](./docs/concepts.md): tool arguments, schema paths and pointers, patching,
178
+ rich text, globals, uploads, drafts and publishing, versions and diffs.
179
+ - [`docs/integration.md`](./docs/integration.md): the endpoint, startup validation, API keys in the
180
+ admin panel, and custom key resolution.
181
+ - [`docs/custom-tools.md`](./docs/custom-tools.md): defining your own tools.
182
+ - [`docs/security.md`](./docs/security.md): authentication, access, limits and known limitations.
183
+ - [`docs/limitations.md`](./docs/limitations.md): what the plugin does not do.
184
+ - [`docs/standards.md`](./docs/standards.md): test and comment standards for contributors.
515
185
 
516
186
  ## License
517
187