@purposeinplay/payload-mcp
v0.7.1
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:
| 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. |
| resolveNestedBlocksPerField | boolean | false | Validate a block nested inside another block's rich-text subfield against the blocks that collection's editors register, not against allowedBlocks. A nested block is admitted only if it also has an explicit blockFieldPolicy entry — registration says the block fits the field, the policy says the AI may write it. Top-level body blocks are unaffected. |
| 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. Admin-owned keys are exempt — see Rate caps. |
| 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. |
Per-target options
A multi-collection config passes targets: [{ collection, label, fieldMap, ... }] instead of a bare
collection. Alongside allowedBlocks, blockFieldPolicy, media and editExisting, a target may
declare extraFields — the allowlist of plain doc fields the AI may write outside the body:
extraFields: [
{ arg: 'seoTitle', targetField: 'meta.title', type: 'text', maxLength: 100 },
{ arg: 'canonicalUrl', targetField: 'meta.canonicalUrl', type: 'url' },
{ arg: 'noIndex', targetField: 'meta.noIndex', type: 'boolean' },
{ arg: 'structuredData', targetField: 'meta.structuredData', type: 'json', maxLength: 10_000 },
]| type | Accepts | Notes |
|---|---|---|
| text | string | Trimmed; maxLength in characters (default 1000). |
| url | string | Text plus the href-scheme guard — javascript:/data:/vbscript: are rejected. |
| number · boolean · date | scalar | date is coerced to an ISO 8601 string. |
| select | string | Requires a non-empty allowedValues, which also becomes the tool-schema enum. |
| json | object/array, or a JSON string | For a Payload json field. Always stored parsed. maxLength bounds the serialised byte length. Prototype-polluting keys are stripped at every depth and nesting past 20 levels is refused, because this value reaches payload.create without passing through any field schema. A bare scalar is rejected. |
| media | positive integer id | For a flat upload field (one not nested in a block — media.refFields covers those). The id is validated server-side before the write: it must exist in the target's media.collection, its mime must match allowedMimePrefixes, and when denySvg is set it must not be an SVG. The target must declare a media policy or the plugin throws at build, since an unpoliced target would write the id unchecked. The AI still cannot upload through this path — it references existing media only, or calls upload_image first (see media.upload). |
| any of the above + hasMany: true | array of that type | Targets a Payload hasMany field. Each element runs through the SAME coercion and guards as a single value, so a hasMany select still rejects a value outside allowedValues and maxLength still bounds each string. maxItems bounds the array itself (default 100) — maxLength alone leaves the list unbounded, and every element of a huge array of one valid value passes its own guard. Supported on text, url, number, date and select; boolean, json and media throw at build, as does maxItems without hasMany. A non-array value is rejected rather than wrapped. |
Only fields declared here are ever written, and none may target a plugin-managed key (_status,
createdBy, id) or a slot already owned by fieldMap/relations — including a nested path under
one.
fieldMap.meta is the one exception to "a mapped slot is off limits", because it maps two keys inside
a group. It reserves the group root and those two keys only, so fieldMap.meta and extraFields compose
on the same group — keep metaTitle/metaDescription from the field-map and reach the rest of the SEO
fields alongside them:
fieldMap: { title: 'title', meta: { field: 'meta' } }, // → metaTitle, metaDescription
extraFields: [
{ arg: 'canonicalUrl', targetField: 'meta.canonicalUrl', type: 'url' },
{ arg: 'noIndex', targetField: 'meta.noIndex', type: 'boolean' },
{ arg: 'structuredData', targetField: 'meta.structuredData', type: 'json', maxLength: 10_000 },
]meta, meta.title and meta.description are still refused — the first would replace the whole group,
the other two are what metaTitle/metaDescription already write. (Before 0.5.1 the entire meta root
was reserved, which made every sibling field unreachable and forced consumers to drop fieldMap.meta
and re-map title and description by hand under different arg names.)
The config-authoring types (McpTarget, ExtraFieldMap, FieldMap, BlockFieldPolicy,
MediaRefPolicyConfig, …) are exported from the package root, so a consumer can type a shared config
fragment — e.g. one extraFields array imported by both the plugin config and a seeding script.
Rate caps
Three independent per-actor, rolling-24h caps. They are a cost/abuse circuit breaker for bot-scoped keys, not a security boundary — draft-only writes, media-ref validation and the ownership fence hold regardless of any cap.
| Cap | Default | Applies to |
|---|---|---|
| dailyCreateCap (per target) | dailyDraftCap (50) | create_draft_*. Counted in the DB from createdBy + createdAt. |
| dailyUpdateCap (per target) | dailyCreateCap when editExisting, otherwise uncapped | update_draft_*. In-process only, so it resets on restart. |
| dailyUploadCap (media.upload) | 100 | upload_image. In-process only. |
Both draft caps can also be set per key, in the admin UI, on the API-key document (sidebar, admin-editable only — an editor cannot raise their own):
| Field on the key | Overrides | Applies to |
|---|---|---|
| dailyCreateCap | the target's dailyCreateCap | create_draft_* — and update_draft_* too, unless the key sets dailyUpdateCap. |
| dailyUpdateCap | the target's dailyUpdateCap | update_draft_*. Honoured even where the target config leaves updates uncapped, so it can tighten as well as raise. |
Resolution for an edit is most-specific-first: the key's dailyUpdateCap, else the key's
dailyCreateCap where the target already establishes a finite update cap, else the target's own
value. The inheritance step exists so that raising the one cap field an admin is most likely to reach
for is not silently inert on the operation that actually happens; the editExisting guard on it means
a create-cap override never newly rate-limits a target whose config left updates unlimited.
Admin-owned keys are exempt from the create and update caps. A human admin running a bulk
backfill is the intended exception, and the create cap — not the update cap — is what a large
first-time import hits. The exemption requires the resolved key owner to hold the admin role and
not be the bot principal, mirroring the publish gate. dailyUploadCap is not exempted; size it
for the largest run you expect.
Two limits worth knowing before you tune a key:
- The cap comes from the key; the budget belongs to the actor. Usage is counted per user — creates
from
createdByin the DB, updates from an in-process log keyed by user + collection — so two keys owned by the same person share one budget, and the cap that applies is whichever key made the call. Give a bulk run its own key and its own service user if you want the budget isolated. - The update and upload caps live in process memory, so they are per-instance and reset on deploy. Only the create cap is counted durably.
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, with each block's field shape (best-effort introspection of the running config): nested group/array subfields, select options, relationTo, hasMany, localized, and the slugs of any nested blocks field. row/collapsible/unnamed-tab/unnamed-group wrappers are flattened, so the reported shape matches what you must send |
| get_my_limits | the calling key's own rate limits, spend and where each limit comes from (read-only diagnostic) |
| list_posts | recent posts (id, title, slug, status). Pass slug to resolve one exact slug to its id; query filters the title with like |
| 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) |
| publish_post | admin only, and only when the target sets allowPublish — see below |
With targets, tool names come from each target's label (label: 'game' → create_draft_game,
list_games, publish_game).
Content is authored as Lexical ({ root: { children: [...] } }), with blocks as
{ type: 'block', fields: { blockType, ... } }; contentMarkdown is a prose-only convenience.
Creating and updating never publishes, regardless of what the model was asked to do. The single
exception is the opt-in publish_<label> tool: it is registered only when allowPublish is set, and
at call time it refuses any key whose owner is not an admin (and always refuses the bot identity).
Every publish and every denied attempt is audit-logged. If you want the stricter original posture —
publishing is a human action in the CMS, full stop — simply leave allowPublish off, which is the
default.
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"). The one exception is a write carrying the one-shot, symbol-keyed grant that only the admin-gatedpublish_<label>tool can set: it is scoped to a single{collection, id}and consumed the instant it authorizes, so nothing else in the same request can ride it to publish a different document.
See ARCHITECTURE.md for the full picture.
