payload-mcp-toolkit 0.8.1 → 0.9.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 (99) hide show
  1. package/README.md +100 -237
  2. package/dist/auth-strategy.js +49 -49
  3. package/dist/auth-strategy.js.map +1 -1
  4. package/dist/components/AgentConnectPill.d.ts +3 -0
  5. package/dist/components/AgentConnectPill.js +121 -0
  6. package/dist/components/AgentConnectPill.js.map +1 -0
  7. package/dist/components/CollectionScopesMatrix.js.map +1 -1
  8. package/dist/components/ConsentPermissions.d.ts +10 -0
  9. package/dist/components/ConsentPermissions.js +165 -0
  10. package/dist/components/ConsentPermissions.js.map +1 -0
  11. package/dist/components/GlobalScopesMatrix.js.map +1 -1
  12. package/dist/components/OAuthView.d.ts +4 -0
  13. package/dist/components/OAuthView.js +269 -0
  14. package/dist/components/OAuthView.js.map +1 -0
  15. package/dist/components/ScopesTable.d.ts +28 -0
  16. package/dist/components/ScopesTable.js +165 -130
  17. package/dist/components/ScopesTable.js.map +1 -1
  18. package/dist/components/agentInstructions.d.ts +2 -0
  19. package/dist/components/agentInstructions.js +34 -0
  20. package/dist/components/agentInstructions.js.map +1 -0
  21. package/dist/components/index.d.ts +2 -0
  22. package/dist/components/index.js +2 -0
  23. package/dist/components/index.js.map +1 -1
  24. package/dist/components/oauth.css +111 -0
  25. package/dist/conflict-detection.js +13 -13
  26. package/dist/conflict-detection.js.map +1 -1
  27. package/dist/draft-workflow.js +26 -26
  28. package/dist/draft-workflow.js.map +1 -1
  29. package/dist/endpoint.d.ts +5 -0
  30. package/dist/endpoint.js +21 -13
  31. package/dist/endpoint.js.map +1 -1
  32. package/dist/hash.js +15 -15
  33. package/dist/hash.js.map +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +67 -3
  36. package/dist/index.js.map +1 -1
  37. package/dist/introspection.js +47 -47
  38. package/dist/introspection.js.map +1 -1
  39. package/dist/oauth-permissions.d.ts +52 -0
  40. package/dist/oauth-permissions.js +100 -0
  41. package/dist/oauth-permissions.js.map +1 -0
  42. package/dist/oauth-store.d.ts +19 -0
  43. package/dist/oauth-store.js +122 -0
  44. package/dist/oauth-store.js.map +1 -0
  45. package/dist/oauth.d.ts +19 -0
  46. package/dist/oauth.js +523 -0
  47. package/dist/oauth.js.map +1 -0
  48. package/dist/prompts.js +5 -5
  49. package/dist/prompts.js.map +1 -1
  50. package/dist/registry.js +7 -1
  51. package/dist/registry.js.map +1 -1
  52. package/dist/resources.js +10 -10
  53. package/dist/resources.js.map +1 -1
  54. package/dist/scope/audit-log.js +9 -9
  55. package/dist/scope/audit-log.js.map +1 -1
  56. package/dist/scope/policy.js +7 -7
  57. package/dist/scope/policy.js.map +1 -1
  58. package/dist/tools/_helpers.d.ts +10 -0
  59. package/dist/tools/_helpers.js +53 -41
  60. package/dist/tools/_helpers.js.map +1 -1
  61. package/dist/tools/_layout-helpers.js +33 -33
  62. package/dist/tools/_layout-helpers.js.map +1 -1
  63. package/dist/tools/create-document.js +20 -19
  64. package/dist/tools/create-document.js.map +1 -1
  65. package/dist/tools/delete-document.js +10 -9
  66. package/dist/tools/delete-document.js.map +1 -1
  67. package/dist/tools/find-document.js +18 -15
  68. package/dist/tools/find-document.js.map +1 -1
  69. package/dist/tools/find-global.js +13 -13
  70. package/dist/tools/find-global.js.map +1 -1
  71. package/dist/tools/global-versions.js +8 -7
  72. package/dist/tools/global-versions.js.map +1 -1
  73. package/dist/tools/patch-global-layout.js +9 -9
  74. package/dist/tools/patch-global-layout.js.map +1 -1
  75. package/dist/tools/patch-layout.js +15 -14
  76. package/dist/tools/patch-layout.js.map +1 -1
  77. package/dist/tools/publish-draft.js +2 -1
  78. package/dist/tools/publish-draft.js.map +1 -1
  79. package/dist/tools/publish-global-draft.js +4 -4
  80. package/dist/tools/publish-global-draft.js.map +1 -1
  81. package/dist/tools/resolve-reference.js.map +1 -1
  82. package/dist/tools/safe-delete.js +14 -14
  83. package/dist/tools/safe-delete.js.map +1 -1
  84. package/dist/tools/schedule-publish.js +21 -20
  85. package/dist/tools/schedule-publish.js.map +1 -1
  86. package/dist/tools/search-content.js +12 -12
  87. package/dist/tools/search-content.js.map +1 -1
  88. package/dist/tools/update-document.js +6 -5
  89. package/dist/tools/update-document.js.map +1 -1
  90. package/dist/tools/update-global.js +9 -9
  91. package/dist/tools/update-global.js.map +1 -1
  92. package/dist/tools/upload-media.js +2 -1
  93. package/dist/tools/upload-media.js.map +1 -1
  94. package/dist/tools/versions.js +10 -8
  95. package/dist/tools/versions.js.map +1 -1
  96. package/dist/types.d.ts +3 -0
  97. package/dist/types.js.map +1 -1
  98. package/docs/oauth.md +96 -0
  99. package/package.json +7 -2
package/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # payload-mcp-toolkit
2
2
 
3
- > Standalone schema-aware MCP plugin for Payload CMS v3. Owns the `/api/mcp` endpoint, scoped API keys, draft workflow, and AI-friendly authoring tools so non-technical editors can manage content via AI chat.
3
+ A Payload CMS v3 plugin that lets AI apps such as Claude and ChatGPT read and edit your content over MCP.
4
4
 
5
- `payload-mcp-toolkit` is a single, self-contained Payload v3 plugin. It introspects your Payload config at boot, registers schema-aware **prompts**, **resources**, and **tools** for any MCP-compatible client (Claude Desktop, Claude API, Continue, Cline), and exposes them over `POST /api/mcp` with bearer-token authentication on a built-in API-keys collection.
5
+ It reads your Payload config when the server starts and builds tools, prompts and resources from your collections, globals and blocks. It serves them at `/api/mcp`. Clients connect in one of two ways:
6
6
 
7
- It is the standalone successor to the toolkit's earlier wrapper around `@payloadcms/plugin-mcp` see [Upgrading from 0.3.x](#upgrading-from-03x) below.
7
+ - **API keys** for scripts and local agents. Each key has its own permissions.
8
+ - **Website sign-in (OAuth)** for Claude and ChatGPT connectors. Users sign in with their site account and choose what the AI may do. Optional. See [docs/oauth.md](docs/oauth.md).
9
+
10
+ Payload access control applies to every call. Tools run as the signed-in user or the key's user, with `overrideAccess: false`.
8
11
 
9
12
  ## Install
10
13
 
@@ -12,174 +15,125 @@ It is the standalone successor to the toolkit's earlier wrapper around `@payload
12
15
  pnpm add payload-mcp-toolkit
13
16
  ```
14
17
 
15
- Peer dependencies: `payload` ^3, `zod` ^3.25 or ^4. (Zod 4 needs
16
- `@modelcontextprotocol/sdk` 1.23 or newer, which this package depends on.)
17
-
18
- ## Configure — zero config
18
+ Peer dependencies: `payload` ^3, `@payloadcms/ui` ^3, `zod` ^3.25 or ^4.
19
19
 
20
20
  ```ts
21
21
  // payload.config.ts
22
22
  import { mcpToolkitPlugin } from 'payload-mcp-toolkit'
23
23
 
24
24
  export default buildConfig({
25
- // ...your collections, blocks, globals
26
- serverURL: process.env.SITE_URL, // used for absolute preview URLs + Host check
27
- admin: { user: 'users' }, // your auth collection
25
+ serverURL: process.env.SITE_URL, // used for preview links and the Host check
26
+ admin: { user: 'users' },
28
27
  plugins: [mcpToolkitPlugin()],
29
28
  })
30
29
  ```
31
30
 
32
- That is the entire integration. The toolkit:
31
+ This adds:
33
32
 
34
- - Adds the `payload-mcp-api-keys` collection (admin UI: **MCP → API Keys**).
35
- - Registers a bearer authentication strategy on your user collection.
36
- - Mounts `POST /api/mcp` and `GET /api/mcp` (the latter returns 405 with a JSON-RPC error so probing clients see something useful).
37
- - Builds tools / prompts / resources from your introspected schema.
33
+ - The `payload-mcp-api-keys` collection (admin: **MCP → API Keys**) and a bearer strategy on your user collection.
34
+ - `POST /api/mcp`. `GET /api/mcp` returns 405.
35
+ - Tools, prompts and resources built from your schema.
38
36
 
39
- Everything else is inferred:
37
+ The plugin works out the rest from your config:
40
38
 
41
- - **Draft behavior** collections with `versions.drafts` get `always-draft` semantics (clients flow through `publishDraft` / `patchLayout` / `updateDocument`); others publish immediately.
42
- - **Preview URLs** pulled from each collection's `admin.livePreview.url` (or `admin.preview` as a fallback). Falls back to a generic admin-panel hint when neither is set.
43
- - **Block nesting** — recorded for every blocks-typed field anywhere in the schema; the AI composes layouts at any depth from that map.
44
- - **User collection** — `admin.user`.
39
+ - **Drafts:** collections and globals with `versions.drafts` save changes as drafts. Others publish on save.
40
+ - **Preview links:** taken from `admin.livePreview.url`, or `admin.preview`.
41
+ - **Blocks:** every blocks field, at any depth, so the AI can build valid layouts.
45
42
 
46
- ## API keys
43
+ Schema changes add database tables or columns. On Postgres or SQLite with `push: false`, generate and commit a Payload migration after installing or upgrading.
47
44
 
48
- Create one in admin (**MCP → API Keys → Create**). The plaintext key is shown once on creation; from then on only its `keyPrefix` (first 8 chars) is visible.
45
+ ## API keys
49
46
 
50
- Authenticate every MCP request with:
47
+ Create a key in **MCP API Keys**. The admin shows the full key once. After that you see only its first 8 characters.
51
48
 
52
49
  ```http
53
- POST /api/mcp HTTP/1.1
54
- Authorization: Bearer <plaintext-key>
50
+ POST /api/mcp
51
+ Authorization: Bearer <key>
55
52
  Content-Type: application/json
56
53
  ```
57
54
 
58
- ### Scopes
59
-
60
- Configure each key's permissions through typed admin fields — no JSON to hand-edit.
61
-
62
- | Field | Effect |
63
- |---|---|
64
- | `preset` | Role preset: **Read-only**, **Editor** (read + create + update on collections; read-only on globals — see below), **Admin** (all actions on both), or **Custom** (use the override fields below). Required. Defaults to **Custom** so new keys deny everything until explicitly scoped. Switching away from Custom **clears every override field on save** (collectionScopes, globalScopes, toolAllow, toolDeny); switching back to Custom starts from a fresh deny-all baseline — reconfigure the matrices before saving. |
65
- | `collectionScopes` | Array of `{ slug, actions[] }`. Only honoured when preset is **Custom**. Each row whitelists a collection and the actions (`read` / `create` / `update` / `delete`) allowed on it. An empty `actions[]` denies all actions on that collection. Listed collections are a *whitelist* — collections not in the list are denied. (Pre-v0.6 rows using `{ collection, actions[] }` are tolerated via a one-release legacy fallback; resave them to migrate.) |
66
- | `globalScopes` | Array of `{ slug, actions[] }`. Only honoured when preset is **Custom** *and* the host config has at least one global. Globals only support `read` and `update` (no `create` / `delete` — they're singletons). Same whitelist semantics as `collectionScopes`. (Pre-v0.6 rows using `{ global, actions[] }` are tolerated via the same legacy fallback.) |
67
- | `toolAllow` | Multi-select. Only honoured when preset is **Custom**. If set, only these tools are callable with this key. An empty list under Custom is treated as deny-all on the tools axis **only when no collection or global scopes are set** (the fresh-Custom-key sentinel); when collection or global scopes are populated, an empty list collapses to "no tool restriction" so the resource scopes alone gate access. To deny every tool while keeping resource scopes, enumerate them in `toolDeny` instead. |
68
- | `toolDeny` | Multi-select. Always applied on top of any preset. Tools listed here are blocked regardless of preset / collection / global scopes. |
69
-
70
- The collection and tool dropdowns are populated at plugin-init time from your live Payload config + the toolkit's registered tools. Adding a collection or custom tool requires a dev-server / app restart for it to surface in the dropdowns.
71
-
72
- The same shape is editable programmatically via Payload's REST and GraphQL APIs against the `payload-mcp-api-keys` collection — useful for seeding keys from CI or scripted provisioning.
73
-
74
- ### Lifecycle fields
75
-
76
- | Field | Effect |
77
- |---|---|
78
- | `name`, `description` | Human-readable identifier in the admin list. |
79
- | `expiresAt` | Authentication rejects keys past this date. |
80
- | `revokedAt` | Authentication rejects keys when set. |
81
- | `lastUsedAt` | Updated fire-and-forget on each successful auth. |
82
- | `keyPrefix` | First 8 chars of the plaintext, for audit-log identification. |
83
-
84
- ## What the plugin adds
85
-
86
- **Auto-generated prompts:**
55
+ Each key has a **preset**:
87
56
 
88
- - `contentModelOverview` every collection, fields, and relationships.
89
- - `blockCompositionGuide` — section/leaf hierarchy and nesting rules.
90
- - `draftWorkflowGuide` which collections need `publishDraft` to go live.
57
+ | Preset | Collections | Globals |
58
+ |---|---|---|
59
+ | Read-only | read | read |
60
+ | Editor | read, create, update | read |
61
+ | Admin | read, create, update, delete | read, update |
62
+ | Custom | only what you tick | only what you tick |
91
63
 
92
- **Auto-generated resources:** `blocks://catalog`, `blocks://nesting`, `collections://schema`, `collections://relationships`. Plus `globals://schema` when the host config has at least one global.
64
+ New keys start as **Custom** with nothing ticked, so they can do nothing until you choose. Custom gives you a collection matrix, a global matrix, a tool allow list and a tool deny list. The deny list applies to every preset.
93
65
 
94
- **Tools (19 total 10 collection-routed, 6 global-routed, 3 account-routed; globals tools register only when the host config has at least one global, and version / publish tools register only on draft-enabled resources):**
66
+ Editor keys cannot change globals. One bad write to a global (site name, footer, navigation) shows on every page. To let a key edit a specific global, use Custom.
95
67
 
96
- *Authoring*
97
- - `createDocument` — local-API based creation for any collection. JSON-string `data`. Defaults to `draft: true` on draft-enabled collections.
98
- - `updateDocument` — local-API based update. Replaces the upstream plugin's `update<Resource>` tools, which crash on collections containing richText/upload/blocks fields.
99
- - `patchLayout` — surgical append/prepend/insertAt/replaceAt against any blocks-typed field. Validates each block recursively against the introspected nesting map.
100
- - `uploadMedia` — fetch a public HTTPS image, validate (SSRF-safe with a streaming size cap), create a Media doc.
68
+ A key limited to some collections or globals also has these rules:
101
69
 
102
- *Discovery*
103
- - `findDocument` read documents by `documentId` or `where` filter, polymorphic across collections. Decorates draft responses with preview URLs when configured.
104
- - `resolveReference` search collections by name/title/slug for relationship IDs.
105
- - `searchContent` — natural-language editor triage (status, recency, missing fields, free text).
70
+ - It gets linked entries as IDs only.
71
+ - `findDocument` refuses filters with a dotted path.
72
+ - Search, reference and upload tools are off, because they reach across every collection.
106
73
 
107
- *Lifecycle / safety*
108
- - `publishDraft` — flip `_status` from draft to published. Recovers from Payload's post-write field-validator quirk (validator throws *after* the new version row commits in some draft+versions setups): on a caught error, the tool re-reads the doc with `draft: false` and only downgrades to a "published-with-warning" response when the live row reflects the current attempt (strictly newer `updatedAt`), so a stale prior publish cannot mask a real failure.
109
- - `schedulePublish` — auto-registered for collections with drafts AND a `publishedAt` date field. Stamps a future `publishedAt`; you wire up the actual flip via Payload Jobs Queue / cron / `beforeRead`.
110
- - `listVersions` — recent saved versions of a draft document.
111
- - `restoreVersion` — roll a document back to a saved version (creates a new version, so reversible).
112
- - `safeDelete` — relationship-aware delete. Walks the relationship graph; refuses with a structured impact summary if the doc has inbound references. Override with `confirm: true`.
113
- - `deleteDocument` — fast unsafe delete (no relationship walk). Use only when you know the doc has no inbound references; prefer `safeDelete` for general use.
74
+ Keys also have `expiresAt`, `revokedAt` and `lastUsedAt`.
114
75
 
115
- *Globals* (registered when the host config has at least one global)
116
- - `findGlobal` — read any global by slug. Stamps a preview URL on draft documents when `admin.livePreview` / `admin.preview` is configured.
117
- - `updateGlobal` — partial-merge update; same prose JSON contract as `updateDocument`. Draft-enabled globals default to `'always-draft'`.
118
- - `patchGlobalLayout` — surgical block-array edits on any blocks-typed field inside a global, at any nesting depth (e.g. `footer.sections`). Registered only when at least one global has a blocks field.
119
- - `publishGlobalDraft`, `listGlobalVersions`, `restoreGlobalVersion` — registered only for globals with `versions: { drafts: true }`. `publishGlobalDraft` uses the same post-write validation recovery as `publishDraft`, with `fallbackLocale: false` on the verify read so localized globals report the literal `_status` of the requested locale.
76
+ ## Website sign-in (OAuth)
120
77
 
121
- ## Globals
78
+ ```ts
79
+ mcpToolkitPlugin({
80
+ oauth: {
81
+ canAuthorize: ({ user }) => user?.role === 'admin', // who may connect; checked on every request
82
+ access: 'editor', // most the site allows; default 'read-only'
83
+ },
84
+ })
85
+ ```
122
86
 
123
- Globals (site-wide singletons such as site settings, navigation, footer) are exposed alongside collections through the tools listed above and a `globals://schema` resource. The admin UI gains a second "Global scopes" matrix beneath "Collection scopes" under the Custom preset; rows are global slugs, columns are `Read` / `Update`.
87
+ Users add `https://YOUR-SITE/api/mcp` as a custom connector in Claude, or in ChatGPT with Developer mode on. Then they sign in and approve access. The approve screen offers the same choices as a Custom key, capped at `access`. Delete and global writes are never allowed this way.
124
88
 
125
- ### Why `editor` is read-only on globals
89
+ The plugin also adds:
126
90
 
127
- The `editor` preset grants read-only access to globals only `admin` (or a Custom key with explicit `globalScopes`) can write them. Collections under `editor` continue to get `read + create + update`.
91
+ - `/admin/mcp-connections`, where users copy the connector URL and agent instructions, and disconnect.
92
+ - A compact "Connect your AI agent" prompt in the admin sidebar.
128
93
 
129
- The asymmetry exists because globals broadcast site-wide on a single write: site name, footer links, social handles, banner text. A typo in a global is visible on every page that consumes it, with no per-document containment to roll back. Editor-tier keys are typically given to AI agents acting on imperfect natural-language instructions, and `"fix the site title"` going wrong is a one-shot vandalism path against the whole site. If you need editor-tier keys to update specific globals, use the Custom preset with a `globalScopes` entry naming the global slug.
94
+ Setup needs a migration, discovery rewrites and two security headers. See [docs/oauth.md](docs/oauth.md).
130
95
 
131
- ## Optional configuration
96
+ ## Tools
132
97
 
133
- Every option is an escape hatch pass only what you need:
98
+ Tools for versions and publishing appear only for collections and globals with drafts. Global tools appear only when the config has globals.
134
99
 
135
- ```ts
136
- mcpToolkitPlugin({
137
- auth: {
138
- allowedOrigins: ['https://app.example.com'], // origin allow-list for the /api/mcp Origin/Host check; browser preflight not yet handled — see Known limitations
139
- },
140
- apiKeyCollection: {
141
- slug: 'mcp-keys', // default 'payload-mcp-api-keys'
142
- userCollection: 'admins', // default admin.user
143
- },
144
- preview: {
145
- siteUrl: 'https://staging.example.com',
146
- disabled: false,
147
- },
148
- draftBehavior: {
149
- posts: 'always-publish', // publish immediately on update
150
- },
151
- userCollection: 'admins',
152
- exclude: {
153
- collections: ['internal-bookkeeping'],
154
- globals: ['secret-config'],
155
- },
156
- mediaUpload: { maxFileSize: 25 * 1024 * 1024, collectionSlug: 'images' },
157
- domainPrompts: [
158
- { name: 'siteVocabulary', title: 'Site Vocabulary', description: 'Site-specific terms.', content: '...' },
159
- ],
160
- })
161
- ```
100
+ | Tool | What it does |
101
+ |---|---|
102
+ | `findDocument` | Read by ID or `where` filter. Draft results include a preview link. |
103
+ | `searchContent` | Find entries by text, status, recent changes or missing fields. |
104
+ | `resolveReference` | Look up IDs by name, title or slug, for relationship fields. |
105
+ | `createDocument` | Create an entry. Draft collections save a draft. |
106
+ | `updateDocument` | Update an entry, including rich text, upload and blocks fields. |
107
+ | `patchLayout` | Append, insert or replace blocks in a blocks field. Checks each block against your schema. |
108
+ | `uploadMedia` | Fetch a public HTTPS image and create a media entry. Size-capped while downloading. |
109
+ | `publishDraft`, `schedulePublish` | Publish a draft now, or set a future `publishedAt`. You run the scheduled publish yourself (Jobs Queue or cron). |
110
+ | `listVersions`, `restoreVersion` | List saved versions and roll back. |
111
+ | `safeDelete` | Delete only if nothing links to the entry, unless `confirm: true`. |
112
+ | `deleteDocument` | Delete without checking links. |
113
+ | `findGlobal`, `updateGlobal`, `patchGlobalLayout` | The same for globals. |
114
+ | `publishGlobalDraft`, `listGlobalVersions`, `restoreGlobalVersion` | Draft tools for globals. |
115
+
116
+ **Prompts:** `contentModelOverview`, `blockCompositionGuide`, `draftWorkflowGuide`.
117
+
118
+ **Resources:** `collections://schema`, `collections://relationships`, `blocks://catalog`, `blocks://nesting`, `globals://schema`.
119
+
120
+ ## Options
162
121
 
163
122
  | Option | Description |
164
123
  |---|---|
165
- | `auth.allowedOrigins` | Origins permitted on the `Origin` header for the DNS-rebinding check. Empty / unset means server-to-server only. `*` is intentionally not honoured. **Note:** browser MCP clients are not yet fully supported — the endpoint does not emit CORS response headers or handle the `OPTIONS` preflight. See [Known limitations](#known-limitations). |
166
- | `apiKeyCollection.slug` | API-keys collection slug. Defaults to `payload-mcp-api-keys` for zero-touch upgrade compatibility. |
167
- | `apiKeyCollection.userCollection` | User collection that API keys link to. Defaults to `userCollection` / `admin.user`. |
168
- | `preview.siteUrl` | Base URL for preview links. Defaults to `serverURL`, then `NEXT_PUBLIC_SERVER_URL`/`SITE_URL` env vars. |
169
- | `preview.disabled` | Suppress preview URL injection on draft responses. |
170
- | `draftBehavior` | Per-collection override of inferred behavior. |
171
- | `userCollection` | Override `admin.user` for API key linkage. |
172
- | `exclude.collections` / `exclude.globals` | Hide from MCP exposure. |
173
- | `domainPrompts` | Site-specific vocabulary prompts. |
174
- | `mediaUpload.maxFileSize` | Default 10MB. Enforced as a streaming cap, not a post-buffer check. |
175
- | `mediaUpload.collectionSlug` | Default `'media'`. |
176
- | `customTools` | Extra tools registered alongside the built-ins. See [Custom tools](#custom-tools). |
124
+ | `oauth` | Website sign-in. See above. |
125
+ | `exclude.collections`, `exclude.globals` | Hide collections and globals from MCP. |
126
+ | `customTools` | Your own tools. See below. |
127
+ | `draftBehavior` | Per-collection override, for example `{ posts: 'always-publish' }`. |
128
+ | `preview.siteUrl`, `preview.disabled` | Base URL for preview links (default `serverURL`), or turn them off. |
129
+ | `mediaUpload.maxFileSize`, `mediaUpload.collectionSlug` | Default 10 MB and `'media'`. |
130
+ | `domainPrompts` | Extra prompts with site vocabulary. |
131
+ | `userCollection` | Override `admin.user`. |
132
+ | `apiKeyCollection.slug`, `apiKeyCollection.userCollection` | Rename the keys collection or link keys to another user collection. |
133
+ | `auth.allowedOrigins` | Origins allowed by the `Origin` check. Unset means server-to-server only. |
177
134
 
178
135
  ## Custom tools
179
136
 
180
- Pass your own tools through `customTools` and they register next to the built-in
181
- ones:
182
-
183
137
  ```ts
184
138
  import { mcpToolkitPlugin, jsonResponse, type ToolFactoryOutput } from 'payload-mcp-toolkit'
185
139
  import { z } from 'zod'
@@ -188,137 +142,46 @@ const countActiveMembers: ToolFactoryOutput = {
188
142
  name: 'countActiveMembers',
189
143
  description: 'Number of members with an active membership.',
190
144
  parameters: { since: z.string().optional().describe('ISO date.') },
191
- // 'account', not 'collection': the target is hard-coded in the handler, so
192
- // there is no argument for the scope check to read. See Scope routing below.
193
145
  routing: { kind: 'account', action: 'read' },
194
146
  handler: async (args, req) => {
195
- const { totalDocs } = await req.payload.count({
196
- collection: 'memberships',
197
- user: req.user,
198
- overrideAccess: false,
199
- })
147
+ const { totalDocs } = await req.payload.count({ collection: 'memberships', user: req.user, overrideAccess: false })
200
148
  return jsonResponse({ totalDocs })
201
149
  },
202
150
  }
203
151
 
204
- plugins: [mcpToolkitPlugin({ customTools: [countActiveMembers] })]
205
- ```
206
-
207
- What you get for free:
208
-
209
- - **The same wrapper as the built-ins** — the scope check runs before your
210
- handler, `req.context.source` is stamped `'mcp'`, and every call (success,
211
- failure, scope rejection) lands in the structured audit log.
212
- - **A slot in the API-key scope dropdowns** — your tool name appears in
213
- **Tool allow** / **Tool deny** alongside the built-ins.
214
- - **A boot-time name check** — reusing a built-in name throws instead of
215
- silently shadowing that tool.
216
-
217
- The field-by-field contract:
218
-
219
- | Field | Notes |
220
- |---|---|
221
- | `name` | Must be unique across built-in and custom tools. |
222
- | `description` | Shown to the model in `tools/list`. Say when to reach for it. |
223
- | `parameters` | A raw Zod shape (`{ key: z.string() }`) or a `z.object({...})`. Both are accepted. |
224
- | `routing` | `{kind, action}` — which scope axis gates the tool. `kind` is `'collection'`, `'global'`, or `'account'`. |
225
- | `handler` | `(args, req, extra) => McpTextResponse`. Read `req.payload` / `req.user` per call; do not close over them at boot. |
226
-
227
- ### Scope routing
228
-
229
- Scope routing reads the target resource from the call's own arguments. A
230
- `collection`-routed tool **must** take a required `collection` argument (a
231
- `global`-routed tool, a required `slug`); the registry reads that value to
232
- decide whether the key's scopes permit the call.
233
-
234
- A `collection`- or `global`-routed tool called without that argument is
235
- **denied**, whatever the key's scopes say. There is no target to check, so the
236
- check cannot pass. Use `routing.kind: 'account'` for a tool whose target is
237
- fixed in the handler or spans the whole install — account-routed tools are
238
- gated by the key's preset instead.
239
-
240
- Making the argument optional is the trap: the call then reaches the scope check
241
- with no target and is refused every time.
242
-
243
- Run queries as the authenticated user (`user: req.user, overrideAccess: false`)
244
- so Payload's own access rules still apply inside the tool. `overrideAccess:
245
- true` hands an MCP client more reach than the user behind its API key.
246
-
247
- ## Upgrading from 0.7.0
248
-
249
- v0.7.1 is a patch release; no API or breaking config changes. The behavioural changes worth knowing:
250
-
251
- - **Preset-switch clears overrides on save.** Switching an API key away from Custom now nulls `collectionScopes`, `globalScopes`, `toolAllow`, and `toolDeny` on save (admin UI conditional-field trap fix — previously, stale Custom-era values silently survived the switch and continued to narrow access). Switching back to Custom starts from a fresh deny-all baseline; reconfigure the matrices before saving.
252
- - **Empty `toolAllow` under Custom + populated resource scopes no longer denies all tools.** When the key carries collection or global scopes and `toolAllow` is empty, it is treated as "no tool restriction" so the resource scopes alone determine what is callable. The fresh-Custom-key sentinel (no scopes anywhere → deny-all) still applies.
253
- - **Legacy non-Custom rows with populated overrides emit a one-time warn.** Keys persisted before v0.7.1 that carry populated `collectionScopes` / `globalScopes` / `toolAllow` arrays under a non-Custom preset still narrow access as written (fail-closed safe), but `composeScopes` now logs `mcp.auth.legacy_non_custom_override` once per process to flag them for audit. Re-save affected keys in admin to align persisted state with the v0.7.1 semantics.
254
- - **Publish tools recover from Payload's post-write validator throw deterministically.** Both `publishDraft` and `publishGlobalDraft` snapshot the document's `updatedAt` before the update and only downgrade a caught error to a `[publishDraft:published_with_warning]` / `[publishGlobalDraft:published_with_warning]` response when the live row reflects the current attempt (strictly newer `updatedAt`). MCP clients can branch on the stable token prefix without regex-matching prose.
255
-
256
- ## Upgrading from 0.6
257
-
258
- v0.7 renames the exported plugin factory so the public symbol matches the package name. Pure rename — no options, runtime behaviour, or scope semantics changed.
259
-
260
- ```diff
261
- - import { contentToolkitPlugin } from 'payload-mcp-toolkit'
262
- + import { mcpToolkitPlugin } from 'payload-mcp-toolkit'
263
-
264
- - plugins: [contentToolkitPlugin()],
265
- + plugins: [mcpToolkitPlugin()],
152
+ mcpToolkitPlugin({ customTools: [countActiveMembers] })
266
153
  ```
267
154
 
268
- ## Upgrading from 0.5
269
-
270
- v0.6 adds globals support across the MCP surface. The changes most likely to surprise an upgrade:
271
-
272
- - **`editor` preset is read-only on globals.** Editor-tier keys cannot `updateGlobal` or `patchGlobalLayout`. Use the `admin` preset or a Custom key with explicit `globalScopes` for editor-tier writes. See [Why `editor` is read-only on globals](#why-editor-is-read-only-on-globals) for the rationale.
273
- - **Audit log field rename.** The per-tool audit field `collectionArg` is replaced by `targetSlug` + `targetKind` (`'collection' | 'global' | 'account' | undefined`). Operators with SIEM rules / dashboards filtering on `collectionArg` must update their queries. The old field is gone — there is no compatibility alias, because the original field misreported for global operations.
274
- - **`tools.allow` without an explicit resource scope is now a deny.** Previously `tools: { allow: ['updateDocument'] }` with no `collections` map and no preset implicitly allowed `updateDocument` on every collection. The fix lands now and applies symmetrically across collections and globals. If your keys rely on the `tools.allow`-only shape (not a documented configuration), add an explicit `collections` / `globals` map or a `preset`.
275
- - **Production deploys need a migration.** Run `pnpm payload migrate:create` after upgrading to capture the new `globalScopes` JSONB column on `payload-mcp-api-keys`. Local dev with `push: true` syncs on the next `pnpm dev`.
155
+ Custom tools go through the same permission check and audit log as the built-in tools. They also appear in the API-key and sign-in tool lists.
276
156
 
277
- ## Upgrading from 0.3.x
157
+ - **`routing`** says which permission gates the tool.
158
+ - `collection` tools must take a required `collection` argument.
159
+ - `global` tools must take a required `slug`.
160
+ - Use `account` when the target is fixed in the handler or spans the whole site. Without the argument, a collection or global tool is always denied.
161
+ - **`handler`** should read `req.payload` and `req.user` on each call. Query with `user: req.user, overrideAccess: false`.
162
+ - **Names** must be unique. Reusing a built-in name throws at startup.
278
163
 
279
- v0.3.x wrapped `@payloadcms/plugin-mcp`. v0.4 owns the small remaining surface (transport, auth, API-key collection, find/delete) directly. The migration is short.
164
+ ## Upgrading
280
165
 
281
- 1. **Remove the upstream plugin** from `plugins[]`:
282
- ```diff
283
- - import { mcpPlugin } from '@payloadcms/plugin-mcp'
284
- - // ...
285
- - plugins: [mcpToolkitPlugin(), mcpPlugin({ ... })],
286
- + plugins: [mcpToolkitPlugin()],
287
- ```
288
- 2. **Drop the dependency** from `package.json`:
289
- ```bash
290
- pnpm remove @payloadcms/plugin-mcp
291
- ```
292
- 3. **Existing API keys keep authenticating zero-touch.** The `payload-mcp-api-keys` slug, `apiKey` / `apiKeyIndex` columns, and HMAC formula are all preserved.
293
- 4. **Re-scope each key** — see the [API keys](#api-keys) section. Open each existing key in admin, pick a preset (or **Custom** with explicit collection / tool overrides), and save. Until you do, keys carry no scopes and authenticate at full access.
294
- 5. **Browser MCP clients are not yet fully supported.** Server-to-server callers (no `Origin` header — backend scripts, Claude Desktop's local connector) work as before and require no opt-in. Browser-based clients additionally need CORS response headers and `OPTIONS` preflight handling, which haven't landed yet — see [Known limitations](#known-limitations).
295
-
296
- If you forget step 1, the plugin throws on boot with the same message — it refuses to register two MCP plugins racing for the `payload-mcp-api-keys` slug.
166
+ - **0.8 0.9:** OAuth is new and off by default. Without it, nothing changes for API-key sites, except one rule. Keys limited to some collections or globals now get linked entries as IDs only, and `findDocument` refuses dotted filter paths for them. Regenerate your import map.
167
+ - **Older versions:** see [CHANGELOG.md](CHANGELOG.md).
297
168
 
298
169
  ## Known limitations
299
170
 
300
- - **Browser MCP clients are not yet fully supported.** The `/api/mcp` endpoint validates the `Origin` / `Host` headers (DNS-rebinding protection) and the `auth.allowedOrigins` option restricts which origins may call it, but the endpoint does not yet emit CORS response headers (`Access-Control-Allow-Origin` etc.) or handle the `OPTIONS` preflight request that browsers send before authenticated cross-origin POSTs. Server-to-server callers (backend scripts, Claude Desktop's local connector — no `Origin` header) are unaffected. Full browser-client support will land in a follow-up release once there is a concrete client to validate against; until then, treat `auth.allowedOrigins` as a server-side allow-list, not a browser opt-in.
171
+ - Browser-based MCP clients are not supported yet. The endpoint does not send CORS headers or answer `OPTIONS` preflight. Server-to-server clients and hosted connectors (Claude, ChatGPT) are not affected.
301
172
 
302
173
  ## Development
303
174
 
304
- This package follows the [official Payload 3 plugin template](https://github.com/payloadcms/payload/tree/main/templates/plugin) layout: source in `src/`, a fully-working Payload + Next.js app in `dev/`, source-export `package.json` so the dev harness consumes the plugin directly without a build step.
175
+ The `dev/` folder is a working Payload and Next.js app that uses the plugin source directly.
305
176
 
306
177
  ```bash
307
178
  pnpm install
308
179
  cp dev/.env.example dev/.env
309
- pnpm dev # boot dev/ Next.js + Payload at http://localhost:3000
310
- pnpm test # vitest — runs the unit + integration suite
311
- pnpm build # produce dist/ for npm publish
180
+ pnpm dev # http://localhost:3000
181
+ pnpm test
182
+ pnpm build
312
183
  ```
313
184
 
314
- The dev harness ships with a realistic CMS schema:
315
-
316
- - `Pages` — block-based layout (FullWidth, TwoColumn, CtaBanner, HeadingOnly), drafts enabled.
317
- - `Posts` — title/slug/excerpt/content/cover/category/authors/tags/SEO, drafts enabled.
318
- - `Authors`, `Categories`, `Media`, `Users` — taxonomy + auth.
319
- - `SiteSettings` — global with site name, logo, social, footer.
320
- - 5 leaf blocks (Heading, RichText, Image, ButtonGroup, Quote) and 4 section blocks.
321
-
322
185
  ## License
323
186
 
324
187
  MIT
@@ -1,10 +1,10 @@
1
1
  import { extractBearerToken, hashKey } from './hash';
2
2
  export const AUTH_STRATEGY_NAME = 'mcp-toolkit-bearer';
3
- /**
4
- * Reads the row's slug, tolerating the pre-0.6 `collection` / `global`
5
- * keys for one release. Logs a one-line warn when the legacy fallback
6
- * fires so operators can spot keys that need re-saving. The fallback is
7
- * scheduled for removal in v0.7.
3
+ /**
4
+ * Reads the row's slug, tolerating the pre-0.6 `collection` / `global`
5
+ * keys for one release. Logs a one-line warn when the legacy fallback
6
+ * fires so operators can spot keys that need re-saving. The fallback is
7
+ * scheduled for removal in v0.7.
8
8
  */ let warnedLegacyShape = false;
9
9
  let warnedLegacyNonCustomOverride = false;
10
10
  /** @internal test-only: reset the one-time legacy warns. */ export function _resetLegacyWarnsForTests() {
@@ -36,40 +36,40 @@ const VALID_GLOBAL_ACTIONS = new Set([
36
36
  'read',
37
37
  'update'
38
38
  ]);
39
- /**
40
- * Builds the runtime `KeyScopes` shape consumed by `registry.assertScopeAllows`
41
- * from the typed scope fields on the api-key row.
42
- *
43
- * Returns null when no typed fields are populated AND no preset is set
44
- * (= full access — back-compat for pre-0.5 rows that pre-date scoped authz).
45
- *
46
- * Two complementary fail-closed rules:
47
- *
48
- * 1. **`'custom'` deny-all sentinel.** `'custom'` is a UI sentinel meaning
49
- * "use my override fields"; it never becomes `KeyScopes.preset` itself.
50
- * Payload persists unset JSON / select fields as `null`, so a fresh
51
- * Custom key with no overrides arrives as `{preset:'custom',
52
- * collectionScopes:null, globalScopes:null, toolAllow:null,
53
- * toolDeny:null}`. That row must deny everything (not fall through to
54
- * full access). The sentinel emits `{collections:{}, globals:{},
55
- * tools:{allow:[]}}`.
56
- *
57
- * 2. **Per-axis explicit-empty, Custom-only.** Under the Custom preset, an
58
- * empty array on any axis (even `[]`) is honoured as written — an empty
59
- * `toolAllow:[]` means "deny all tools on this axis", not "no opinion".
60
- * Under non-Custom presets, empty arrays are IGNORED because Payload's
61
- * hasMany / unpopulated-JSON reads return `[]` for fields the user
62
- * never touched (the override matrices are hidden in the admin UI under
63
- * non-Custom presets via `condition: isCustomPreset`). The on-write
64
- * counterpart of this rule lives in `createApiKeysCollection`'s
65
- * `beforeValidate` hook, which proactively nulls the override axes on
66
- * save when the preset is non-Custom — both layers must stay in sync.
67
- * Non-empty arrays still apply as layered narrowing under any preset.
68
- * `toolDeny` is a deny-list, so an empty array carries no entries — it
69
- * is dropped rather than emitted. NOTE: legacy non-Custom rows persisted
70
- * BEFORE v0.7.1 with populated stale override arrays continue to narrow
71
- * on read until each row is manually re-saved; the on-write fix only
72
- * applies to fresh writes.
39
+ /**
40
+ * Builds the runtime `KeyScopes` shape consumed by `registry.assertScopeAllows`
41
+ * from the typed scope fields on the api-key row.
42
+ *
43
+ * Returns null when no typed fields are populated AND no preset is set
44
+ * (= full access — back-compat for pre-0.5 rows that pre-date scoped authz).
45
+ *
46
+ * Two complementary fail-closed rules:
47
+ *
48
+ * 1. **`'custom'` deny-all sentinel.** `'custom'` is a UI sentinel meaning
49
+ * "use my override fields"; it never becomes `KeyScopes.preset` itself.
50
+ * Payload persists unset JSON / select fields as `null`, so a fresh
51
+ * Custom key with no overrides arrives as `{preset:'custom',
52
+ * collectionScopes:null, globalScopes:null, toolAllow:null,
53
+ * toolDeny:null}`. That row must deny everything (not fall through to
54
+ * full access). The sentinel emits `{collections:{}, globals:{},
55
+ * tools:{allow:[]}}`.
56
+ *
57
+ * 2. **Per-axis explicit-empty, Custom-only.** Under the Custom preset, an
58
+ * empty array on any axis (even `[]`) is honoured as written — an empty
59
+ * `toolAllow:[]` means "deny all tools on this axis", not "no opinion".
60
+ * Under non-Custom presets, empty arrays are IGNORED because Payload's
61
+ * hasMany / unpopulated-JSON reads return `[]` for fields the user
62
+ * never touched (the override matrices are hidden in the admin UI under
63
+ * non-Custom presets via `condition: isCustomPreset`). The on-write
64
+ * counterpart of this rule lives in `createApiKeysCollection`'s
65
+ * `beforeValidate` hook, which proactively nulls the override axes on
66
+ * save when the preset is non-Custom — both layers must stay in sync.
67
+ * Non-empty arrays still apply as layered narrowing under any preset.
68
+ * `toolDeny` is a deny-list, so an empty array carries no entries — it
69
+ * is dropped rather than emitted. NOTE: legacy non-Custom rows persisted
70
+ * BEFORE v0.7.1 with populated stale override arrays continue to narrow
71
+ * on read until each row is manually re-saved; the on-write fix only
72
+ * applies to fresh writes.
73
73
  */ export function composeScopes(row, logger) {
74
74
  const presetRaw = row.preset;
75
75
  const hasPreset = typeof presetRaw === 'string' && presetRaw.length > 0;
@@ -160,13 +160,13 @@ const VALID_GLOBAL_ACTIONS = new Set([
160
160
  }
161
161
  return out;
162
162
  }
163
- /**
164
- * Builds the Payload `auth.strategies` entry that authenticates MCP requests.
165
- *
166
- * Authenticates `Authorization: Bearer <plaintext>` by computing the
167
- * upstream-compatible `apiKeyIndex` HMAC and looking up the row. On match,
168
- * it fires a non-blocking `lastUsedAt` write and hydrates `req.user` with
169
- * the linked user record + key context for downstream scope checks.
163
+ /**
164
+ * Builds the Payload `auth.strategies` entry that authenticates MCP requests.
165
+ *
166
+ * Authenticates `Authorization: Bearer <plaintext>` by computing the
167
+ * upstream-compatible `apiKeyIndex` HMAC and looking up the row. On match,
168
+ * it fires a non-blocking `lastUsedAt` write and hydrates `req.user` with
169
+ * the linked user record + key context for downstream scope checks.
170
170
  */ export function createBearerStrategy(options) {
171
171
  const { collectionSlug, userCollection } = options;
172
172
  return {
@@ -250,9 +250,9 @@ const VALID_GLOBAL_ACTIONS = new Set([
250
250
  }
251
251
  };
252
252
  }
253
- /**
254
- * Reads the per-request API-key context populated by the bearer strategy.
255
- * Returns null for non-MCP requests (e.g. cookie-authenticated admin users).
253
+ /**
254
+ * Reads the per-request API-key context populated by the bearer strategy.
255
+ * Returns null for non-MCP requests (e.g. cookie-authenticated admin users).
256
256
  */ export function getApiKeyContext(req) {
257
257
  const user = req.user;
258
258
  return user?._mcpKey ?? null;