@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +331 -194
  3. package/dist/api-keys/collection.mjs +3 -3
  4. package/dist/api-keys/fields.mjs +56 -5
  5. package/dist/api-keys/setup-guide.mjs +56 -0
  6. package/dist/auth/resolve.mjs +5 -7
  7. package/dist/capabilities.mjs +23 -4
  8. package/dist/client/index.d.mts +2 -0
  9. package/dist/client/index.mjs +2 -0
  10. package/dist/client/setup-guide.d.mts +14 -0
  11. package/dist/client/setup-guide.mjs +87 -0
  12. package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
  13. package/dist/endpoint/handler.mjs +11 -5
  14. package/dist/endpoint/index.mjs +4 -0
  15. package/dist/endpoint/server.mjs +18 -26
  16. package/dist/i18n.mjs +4 -15
  17. package/dist/index.d.mts +4 -4
  18. package/dist/index.mjs +4 -3
  19. package/dist/options.mjs +31 -26
  20. package/dist/plugin.mjs +1 -0
  21. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  22. package/dist/request.mjs +8 -0
  23. package/dist/result.d.mts +11 -0
  24. package/dist/result.mjs +20 -0
  25. package/dist/schema/describe.mjs +3 -15
  26. package/dist/schema/index.mjs +8 -0
  27. package/dist/schema/lexical-pointer.mjs +125 -0
  28. package/dist/schema/lexical.mjs +195 -27
  29. package/dist/schema/outline.mjs +67 -0
  30. package/dist/schema/pointer.mjs +77 -30
  31. package/dist/schema/shape.mjs +133 -51
  32. package/dist/schema/walk.mjs +44 -64
  33. package/dist/tools/{index.mjs → builtin.mjs} +8 -5
  34. package/dist/tools/create-document.mjs +34 -15
  35. package/dist/tools/describe-schema.mjs +21 -7
  36. package/dist/tools/find-documents.mjs +13 -6
  37. package/dist/tools/get-document.mjs +45 -11
  38. package/dist/tools/list-capabilities.mjs +19 -9
  39. package/dist/tools/names.mjs +2 -1
  40. package/dist/tools/patch-document.mjs +32 -21
  41. package/dist/tools/publish-document.mjs +79 -0
  42. package/dist/tools/shared.mjs +84 -32
  43. package/dist/tools/target.mjs +7 -11
  44. package/dist/tools/validate-document.mjs +20 -12
  45. package/dist/types.d.mts +115 -42
  46. package/dist/types.mjs +3 -4
  47. package/dist/version.mjs +1 -1
  48. package/dist/write/draft-guard.mjs +47 -44
  49. package/dist/write/patch.mjs +174 -92
  50. package/dist/write/publish-blockers.mjs +13 -12
  51. package/dist/write/publish-intent.mjs +17 -0
  52. package/dist/write/transaction.mjs +8 -3
  53. package/package.json +24 -9
  54. package/dist/i18n.d.mts +0 -1
  55. package/dist/options.d.mts +0 -2
  56. package/dist/schema/lexical.d.mts +0 -1
  57. package/dist/schema/walk.d.mts +0 -3
  58. package/dist/tools/target.d.mts +0 -3
  59. package/dist/tools/types.d.mts +0 -5
  60. 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 Payload CMS plugin that mounts an MCP (Model Context Protocol) server whose
4
- tool surface stays small and accurate regardless of the size of the
5
- content model.
6
-
7
- Instead of generating one tool per collection with the full document schema
8
- inlined, the plugin types its surface in three layers. The tool signatures are
9
- small and static: collection slugs, locales and operations as enums, everything
10
- else scalars. The field shapes are pulled on demand through `describeSchema`,
11
- one node at a time, stopping at every blocks boundary. And every write is
12
- resolved server-side against the real config and the real document, so unknown
13
- fields, misplaced blocks and unusable rich text nodes or node fields are refused with the
14
- valid alternatives listed, never silently dropped.
15
-
16
- Writes are RFC 6902 patches that always land as drafts; publishing stays a
17
- human action in the admin panel. Every write returns the publish
18
- blockers: the validation failures that still prevent a human from publishing
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
+ ![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.
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: true },
44
- posts: { read: true, write: true },
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: true },
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
- JSON responses; `GET`/`DELETE` answer 405),
60
- - an `mcpx-api-keys` collection (admin group "MCP") holding the keys and their
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, including writes made by custom tools.
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
- ## Connecting a client
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
- The endpoint speaks streamable HTTP with `Authorization: Bearer <key>`:
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 (no direct HTTP header support) via `mcp-remote`:
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
- 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.
128
-
129
- | Tool | Purpose | Key arguments |
130
- | ------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
131
- | `listCapabilities` | What this key may do; call first to orient. | none |
132
- | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection` \| `global`, `paths?`, `expand?` |
133
- | `findDocuments` | Query documents. | `collection`, `where?`, `sort?`, `limit?`, `page?`, `depth?`, `select?`, `locale?`, `draft?` |
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?` |
136
- | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
137
- | `validateDocument` | Publish blockers without writing. | `collection` + `id` \| `global`, `locale` |
138
-
139
- Rules the tools enforce and explain in their own descriptions:
140
-
141
- - `describeSchema` paths stop at blocks fields, which list the block slugs they
142
- accept; every node carries `next`, the ready-to-use paths for those blocks
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
- - Rich text paths continue the same way. A `richText` field lists the Lexical
146
- node types it accepts in `nodes`, and `next` carries a path for every node
147
- type that holds fields of its own: `/content/link` for a link node,
148
- `/content/block/callout` and `/content/inlineBlock/badge` for the block
149
- nodes. Descending returns the real field list, so a link extended through
150
- `LinkFeature({ fields })` and a Lexical block are both described rather than
151
- guessed. Any feature declaring `getSubFields` is picked up, custom ones
152
- included. `upload` nodes are the exception: their fields depend on the
153
- collection the node points at, so they are not addressable.
154
- - Constraints a field declares travel with it: `minRows`/`maxRows` on arrays
155
- and blocks fields, `maxLength`/`minLength` on text, `min`/`max` on numbers.
156
- An array is described in its own right, so the `*` in `/items/*/title` has
157
- something to read; a group or named tab only when it declares a description
158
- or a constraint of its own.
159
- - A `richText` field also reports `nodeOptions`, the node properties its editor
160
- narrows. An editor built with `HeadingFeature({ enabledHeadingSizes: ["h4"] })`
161
- answers `{ "heading": { "tag": ["h4"] } }`, and a write carrying any other
162
- heading tag is refused. Lexical stores whatever tag it is given, so this is
163
- the only place the restriction is checked.
164
- - Field and collection `admin.description` values are included in
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; `readOnly` fields are
186
- listed but refused on write.
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: true } },
196
- globals: { "site-settings": { read: true, write: true } },
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, and because JSON Schema cannot
201
- state either one, both are enforced in the handler and repeated in every
202
- affected tool description:
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: there is
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. Keys issued before a global was exposed have
215
- no such group, and an absent checkbox reads as `false`, so they stay closed to
216
- every global until one is ticked.
217
-
218
- Globals always carry `updatedAt` — Payload appends it and there is no
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
- If `tools/list` omits `global` entirely, no global is exposed to that key; the
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
- ## Drafts and publish blockers
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
- Draft-only writing is enforced on the Payload operation, not in the tool
231
- handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
232
- from every write carrying the MCP request marker, so custom tools and anything
233
- else writing through the same request are covered too. A `beforeChange` hook
234
- refuses any write that would still not land as a draft. Globals expose the same
235
- `beforeOperation` interception point at the same position in the operation, so
236
- they are guarded exactly as strongly as collections, exposed or not.
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
- as `publishBlockers` with paths and labels. The write stands; the client gets a
242
- checklist of what remains. Three limits: only the written locale is
243
- validated; field `beforeChange` hooks run again during the check, so they must
244
- be pure; and the check runs privileged, so blocker paths and messages may name
245
- fields the key's user cannot read (values are never included).
246
- Collections with `versions.drafts.validate: true` refuse invalid drafts
247
- outright; those failures come back as `validationErrors`. Both carry pointers,
248
- restated from the dotted paths Payload reports internally.
249
-
250
- Writes also report `notApplied`: pointers whose value Payload kept unchanged,
251
- which happens when field-level access denies the update.
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
- Each custom tool gets its own checkbox on every API key, default off.
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
- Custom tool shapes are registered as given, and the MCP SDK wraps them in a
282
- non-strict object: unknown arguments are stripped before your handler runs.
283
- Builtin tools reject them instead.
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
- ## Options
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
- | Option | Default | Description |
288
- | ------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
289
- | `collections` | required | Allow-list. `true` means `{ read: true }`. |
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