@purposeinplay/payload-mcp
v0.2.0
Published
Payload CMS 3 plugin: draft documents via Claude Code (MCP). Wraps @payloadcms/plugin-mcp with a hardened, least-privilege, draft-only authoring tool surface.
Readme
@purposeinplay/payload-mcp
Draft Payload CMS documents through Claude Code (Model Context Protocol). This plugin is a thin
wrapper around the official @payloadcms/plugin-mcp: it
reuses that plugin's maintained transport and API-key auth, and layers a hardened, draft-only
authoring tool surface on top that accepts full Lexical content (including rich blocks like
Banner and FAQ).
How it wraps @payloadcms/plugin-mcp
payloadMcpPlugin() composes the official plugin (never forks it) and then hardens the host config.
The official plugin owns the plumbing; we own the draft-authoring tools and the security controls:
flowchart TB
CC["Claude Code<br/>(MCP client)"]
subgraph AIW["payloadMcpPlugin() — this package"]
direction TB
OFF["@payloadcms/plugin-mcp — composed inside (delegated)<br/>• Streamable-HTTP transport at /api/mcp<br/>• bearer / API-key auth<br/>• payload-mcp-api-keys collection"]
OURS["what we add / customize on top<br/>• create_draft_post · update_draft_post (Lexical + blocks)<br/>• read tools: list_authors · list_categories · list_block_types · list_posts · get_post<br/>• block allowlist · href + media-ref validation · no file uploads<br/>• draft-only beforeChange guard (survives prompt injection)<br/>• deny-bot access on every collection + global<br/>• overrideAuth attaches the key's user for attribution"]
OFF --> OURS
end
CC -->|"Bearer <MCP key>"| AIW
AIW -->|"Local API · overrideAccess · draft:true"| DB[("Payload<br/>draft posts only")]| Delegated to @payloadcms/plugin-mcp | Added / customized by this plugin |
|---|---|
| MCP Streamable-HTTP transport (POST /api/mcp, SSE off) | Custom create_draft_post / update_draft_post tools accepting Lexical + blocks |
| Bearer / API-key authentication | Read helper tools (authors, categories, block types, posts, get) |
| The payload-mcp-api-keys collection + per-key tool toggles | Content validation: block allowlist, href sanitize, media-ref checks, no uploads |
| JSON-RPC plumbing & request lifecycle | Draft-only beforeChange guard + createdBy attribution |
| — | Deny-bot access wrap (a leaked key can only draft) |
| — | Generic collection CRUD tools left disabled — writes go through our tools only |
The composition, in one place (src/plugin.ts):
// 1) compose the official plugin: transport + auth + our custom tools
config = await mcpPlugin({
userCollection: authCollection,
overrideAuth: async (req, getDefault) => { // resolve the key's user...
const s = await getDefault()
if (s?.user) req.user = s.user // ...and attach it for attribution
return s
},
mcp: {
handlerOptions: { disableSse: true },
tools: buildPayloadMcpTools({ /* draft + read tools, validator, media policy */ }),
// no `collections` passed → the official plugin exposes no generic CRUD tools
},
})(config)
// 2) harden the (now-extended) config: deny-bot everywhere, draft-guard + createdBy on the target
config.collections = config.collections.map(wrapWithDenyBotAndDraftGuard)Least-privilege and draft-only by design: the AI can only ever create/edit drafts — a human reviews and publishes in the CMS. A leaked key grants nothing over raw REST. Every draft is attributed to the acting user. The media/upload vector (SVG laundering) stays closed.
New here? Start with INTEGRATION.md. Internals: ARCHITECTURE.md and the system map. Recipes + troubleshooting: USAGE.md.
Installation
pnpm add @purposeinplay/payload-mcp @payloadcms/plugin-mcpPeers (a Payload 3.86 app already has the first three): payload, @payloadcms/richtext-lexical,
zod, and @payloadcms/plugin-mcp (the official MCP plugin this wraps).
Quick start
// payload.config.ts
import { buildConfig } from 'payload'
import { payloadMcpPlugin } from '@purposeinplay/payload-mcp'
export default buildConfig({
// ...
plugins: [
// register LAST so deny-bot also covers collections other plugins inject
payloadMcpPlugin({
collection: 'posts', // the collection to draft into (needs versions.drafts)
basePath: '/blog', // your Next basePath, if any (for the returned admin URL)
// Rich-text blocks the AI may emit.
allowedBlocks: ['banner', 'code', 'faq', 'youtubeBlock', 'mediaBlock'],
// Let the AI reference EXISTING library images by id (it can never upload a file). Every ref is
// validated server-side: must exist, be image/*, and not be an SVG.
media: { refFields: { mediaBlock: ['media'] } },
}),
],
})Then run a migration (the plugin adds fields + the payload-mcp-api-keys collection), create the bot
user, mint an MCP API key, and connect Claude Code. Full steps in
INTEGRATION.md.
Options
| Option | Type | Default | Notes |
|---|---|---|---|
| collection | string | — (required) | The collection the AI drafts into. Must have versions.drafts enabled. |
| allowedBlocks | string[] | [] | blockType slugs the AI may emit in rich text. Security allowlist — omit upload/media-bearing blocks. |
| allowUploadNodes | boolean | false | Permit raw Lexical upload nodes. Leave off — use a media block + media instead. |
| media | { collection?, refFields?, allowedMimePrefixes?, denySvg? } | — | Enable image blocks by letting the AI reference existing media by id (never upload). refFields maps blockType → its upload field names (e.g. { mediaBlock: ['media'] }). Each ref is validated: exists · mime in allowedMimePrefixes (default ['image/']) · not SVG (denySvg, default true). Omit to keep image blocks off. |
| basePath | string | '' | The host app's Next basePath (e.g. /blog). Prepended to the returned admin URL. |
| authCollection | string | admin.user | Collection the MCP key's user (bot) belongs to. |
| authorsCollection | string | authors | Collection authors are resolved from (by slug). |
| categoriesCollection | string | categories | Collection categories are resolved from (by slug). |
| botEmail | string | PAYLOAD_MCP_BOT_EMAIL | Email of the bot user drafts are attributed to. |
| dailyDraftCap | number | 50 (PAYLOAD_MCP_DAILY_DRAFT_CAP) | Per-user drafts per rolling 24h. |
| serverUrl | string | config.serverURL | Public origin for the returned adminUrl. |
| verboseLogs | boolean | false | Emit the official plugin's MCP logs. |
| enabled | boolean | true | Set false to register nothing. |
Authentication
Auth is handled by the official plugin's payload-mcp-api-keys collection, made per-editor and
scoped: each editor creates/sees/manages only their own key, admins see all, and the bot /
leaked-key principal + anonymous get nothing. Every draft is attributed to the key's user
(createdBy), so you get real per-editor attribution and per-editor revocation. Claude Code connects
with the key as a bearer — no static env token, no OAuth server. Who counts as "admin" is the
isAdmin option (default: user.roles includes 'admin').
Each API-key page shows a Connect Claude Code panel — the ready-to-paste claude mcp add …
command for that key (endpoint URL + bearer) with a copy button — so editors can connect without
touching config. (Admin UI component; run pnpm generate:importmap in the consumer after install.)
Tool surface (MCP)
| Tool | Does |
|---|---|
| list_authors | slug + name of author bylines |
| list_categories | slug + title of categories |
| list_block_types | the block slugs allowed in content (+ field shapes, best-effort) |
| list_posts | recent posts (id, title, slug, status) |
| get_post | one post, content rendered back to markdown |
| create_draft_post | new draft from Lexical content (or contentMarkdown) → id + adminUrl |
| update_draft_post | edit a draft the actor created (stays a draft) |
Content is authored as Lexical ({ root: { children: [...] } }), with blocks as
{ type: 'block', fields: { blockType, ... } }; contentMarkdown is a prose-only convenience.
There is deliberately no publish tool — publishing is a human action in the CMS.
HTTP endpoint
POST ${app}/api/mcp— the MCP Streamable-HTTP endpoint (owned by@payloadcms/plugin-mcp; SSE disabled, soGETreturns a JSON-RPC "method not allowed" and never holds a stream open).
Security model (at a glance)
- Bot identity is a plugin-injected
payloadMcpBotmarker on the auth collection (not a role). It — and anypayload-mcp-api-keysprincipal — is denied all normal CRUD on every collection- global, so raw REST with the key returns 401/403. The only write path is the MCP tools.
- The tools write via Local API with
overrideAccess, constrained by: a block allowlist (unknown blocks rejected), rejection of rawuploadnodes, href sanitization (including inside nested block rich text), slug resolution (unknown authors/categories rejected, never auto-created), and a per-user daily cap. Block subfield shapes are validated bypayload.createagainst the collection's own config. - Images: the AI can never upload a file (no binary path; the bot can't create media). With
mediaset it may reference existing library media by id, and every ref is validated server-side — must exist, beimage/*, and not be an SVG — so a malicious/wrong asset can't be embedded. - Draft-only is enforced by a
beforeChangeinvariant keyed on the MCP request (and the bot marker) — it holds even underoverrideAccessand survives prompt-injection ("publish this").
See ARCHITECTURE.md for the full picture.
