7.2 KiB
| title | aliases | tags | sources | created | updated | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| MCP Plugin — Model Context Protocol for Payload CMS |
|
|
|
2026-05-15 | 2026-05-15 |
Overview
@payloadcms/plugin-mcp exposes your Payload CMS as an MCP server. AI clients (Claude Code, Cursor, VS Code Copilot, etc.) can then perform CRUD operations on your collections and globals using Bearer-token API keys.
Installation
pnpm add @payloadcms/plugin-mcp
import { buildConfig } from 'payload'
import { mcpPlugin } from '@payloadcms/plugin-mcp'
export default buildConfig({
plugins: [
mcpPlugin({
collections: {
posts: { enabled: true },
},
}),
],
})
Access Control — Two-Step Pattern
Enabling in config alone is NOT enough. Both steps are required.
Step 1 — Enable in plugin config:
mcpPlugin({
collections: { posts: { enabled: true } },
globals: { 'site-settings': { enabled: { find: true, update: true } } },
})
Step 2 — Allow in the API Key:
Admin panel → MCP → API Keys → create key → toggle individual capabilities.
All MCP requests require Authorization: Bearer <key>. Requests without a valid key are rejected immediately.
Key Options Reference
| Option | Type | Description |
|---|---|---|
collections[slug].enabled |
boolean | { find, create, update, delete } |
Granular CRUD permissions |
globals[slug].enabled |
boolean | { find, update } |
Globals are singletons — no create/delete |
collections[slug].overrideResponse |
function |
Intercept/sanitize response before the model sees it |
userCollection |
CollectionSlug |
Which collection holds users for API key linking |
overrideApiKeyCollection |
function |
Extend the auto-generated API Keys collection |
overrideAuth |
function |
Replace Bearer-token auth with custom strategy |
disabled |
boolean |
Disable plugin while keeping DB schema consistent |
mcp.tools |
array |
Custom tools (Zod schema + handler) |
mcp.prompts |
array |
Custom prompts with argsSchema |
mcp.resources |
array |
Static or dynamic resources (URI templates) |
mcp.handlerOptions.onEvent |
function |
Audit/analytics callback for every MCP event |
mcp.handlerOptions.maxDuration |
number |
Request timeout in seconds (default: 60) |
Connecting MCP Clients
Claude Code
claude mcp add --transport http Payload http://127.0.0.1:3000/api/mcp \
--header "Authorization: Bearer MCP-USER-API-KEY"
Cursor / VS Code (via mcp-remote)
{
"mcpServers": {
"Payload": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/api/mcp",
"--header", "Authorization: Bearer MCP-USER-API-KEY"]
}
}
}
Direct HTTP
{
"mcpServers": {
"Payload": {
"type": "http",
"url": "http://localhost:3000/api/mcp",
"headers": { "Authorization": "Bearer MCP-USER-API-KEY" }
}
}
}
Custom Tools / Prompts / Resources
Tool — custom business logic
mcp: {
tools: [{
name: 'getPostScores',
description: 'Get scores about posts since a given date',
handler: async (args, req) => {
const stats = await req.payload.find({
collection: 'posts',
where: { createdAt: { greater_than: args.since } },
req,
overrideAccess: false, // respect API key owner's access
user: req.user,
})
return { content: [{ type: 'text', text: `${stats.totalDocs} posts` }] }
},
parameters: z.object({ since: z.string() }).shape,
}]
}
Prompt
prompts: [{
name: 'reviewContent',
argsSchema: { content: z.string(), criteria: z.array(z.string()) },
handler: ({ content, criteria }, req) => ({
messages: [{ role: 'user', content: { type: 'text', text: `...` } }]
}),
}]
Resource (static + dynamic)
resources: [
{ name: 'guidelines', uri: 'guidelines://company', mimeType: 'text/markdown', handler: (uri, req) => ({ contents: [...] }) },
{ name: 'userProfile', uri: new ResourceTemplate('users://profile/{userId}', { list: undefined }), ... }
]
Globals
Globals get two MCP tools per global: findSiteSettings and updateSiteSettings.
No create or delete — singletons managed by Payload.
Hooks Integration
Detect MCP context in collection hooks via req.payloadAPI === 'MCP':
hooks: {
beforeRead: [
({ doc, req }) => {
if (req.payloadAPI === 'MCP') {
doc.title = `${doc.title} (MCP Override)`
}
return doc
},
],
}
Token Efficiency Tips
selectparameter — pass{ select: '{"title": true, "slug": true}' }to limit fields returned; without it the full document is returned every timeoverrideResponse— strip sensitive/irrelevant fields server-side before the model sees them- Enable only needed ops — each extra operation (create/update/delete) adds tools to the model's context budget
- Strong descriptions — precise collection descriptions help the model pick the right tool on the first try
// Redact sensitive fields
overrideResponse: (response, doc, req) => {
response.content = response.content.map((item) => ({
...item,
text: item.text.replace(/"hash":\s*"[^"]*"/g, '"hash": "[redacted]"'),
}))
return response
}
Localization
When localization is enabled, all collection/global tools automatically expose locale and fallbackLocale parameters — no extra config needed.
Testing
# MCP Inspector (interactive)
npx @modelcontextprotocol/inspector
# → set URL: http://127.0.0.1:3000/api/mcp
# → add header: Authorization: Bearer MCP-USER-API-KEY
# curl test
curl -i 'http://localhost:3000/api/mcp' \
-X POST \
-H 'Authorization: Bearer MCP-USER-API-KEY' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":"1","method":"tools/list","params":{}}'
Key Takeaways
- Two-step access control: plugin config AND API Key admin toggle — both required
- All requests need
Authorization: Bearer <key>; existing Payload access rules still apply - Use
selectto avoid sending full documents to models — major token savings on rich-text collections - Use
overrideResponseto sanitize sensitive fields (passwords, hashes, PII) before LLM sees them req.payloadAPI === 'MCP'flag lets hooks behave differently for MCP vs REST/GraphQL traffic- Only enable operations the model actually needs — unnecessary tools waste context budget
- Virtual fields are excluded from create/update schemas but appear in find responses
- Custom Tools receive
req.payloadfor full Payload Local API access; always passoverrideAccess: false