@brainwebuk/payload-plugin-mcp-oauth
OAuth 2.1 + PKCE + Dynamic Client Registration for
@payloadcms/plugin-mcp,
so a Payload-backed MCP server can be added as a Custom Connector in Claude.ai
alongside the existing API-key flow.
The plugin is purely additive: it wraps the MCP endpoint handler and adds the
OAuth endpoints and collections. Your existing API-key MCP clients keep working
unchanged.
- OAuth 2.1 authorization-code flow with PKCE (S256 only)
- Dynamic Client Registration (RFC 7591) — Claude.ai self-registers
- Discovery via RFC 8414 / RFC 9728 well-known documents
- Tokens hashed at rest (HMAC-SHA-256); refresh + revocation supported
- OAuth Clients and OAuth Tokens appear as admin collections under the
MCP nav group (admin-only; the public REST/GraphQL surface stays closed)
Installing with an AI coding agent? Point it at
INSTALL_FOR_AGENTS.md (shipped in the
npm package too) — a step-by-step playbook with per-step verification and the
failure modes to watch for. Or just follow the manual steps below.
Requirements
| Version |
|---|
payload | ^3.0.0 |
@payloadcms/plugin-mcp | ^3.0.0 (tested 3.85.0) |
next | ^14 || ^15 || ^16 (only for the exported proxy/middleware) |
| Node | >= 20 |
Install
1. Add the package
pnpm add @brainwebuk/payload-plugin-mcp-oauth
# or: npm i / yarn add
2. Register the plugin (after mcpPlugin)
In payload.config.ts, register payloadMcpOAuth() immediately after
mcpPlugin(), and pass it the same options object you gave to mcpPlugin().
import { mcpPlugin } from '@payloadcms/plugin-mcp'
import type { MCPPluginConfig } from '@payloadcms/plugin-mcp'
import { payloadMcpOAuth } from '@brainwebuk/payload-plugin-mcp-oauth'
import { buildConfig } from 'payload'
// Assign ONCE to a const and reuse the same reference in both calls. ⚠️
const mcpOptions: MCPPluginConfig = {
collections: {
users: { enabled: { find: true, update: true } },
media: { enabled: { find: true, create: true } },
},
}
export default buildConfig({
// ...db, collections, admin, etc.
plugins: [
mcpPlugin(mcpOptions),
payloadMcpOAuth({
issuer: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
mcpPluginOptions: mcpOptions, // ← the SAME object, not a copy
}),
],
})
⚠️ Pass the same object reference to both calls. The plugin installs its
token-validation hook by mutating mcpOptions. If you pass a fresh object or a
spread/copy to either call, OAuth tokens will silently fail to authenticate
(the API-key path keeps working, which makes this easy to miss). The plugin
also throws on boot if it is registered before mcpPlugin().
Design note — why we mutate mcpPluginOptions. Payload's plugin guidance
says "never mutate the incoming config," and everything this plugin adds
(collections, endpoints) is done by spreading the incoming config, not mutating
it. The one deliberate exception is mcpPluginOptions: the OAuth token
validator has to live inside @payloadcms/plugin-mcp's request-handler closure,
which Payload captures when that plugin runs — so we must set overrideAuth on
the shared options object before mcpPlugin() executes (hence the
same-reference rule above). This mutates a sibling plugin's options, which the
Plugin API explicitly permits
(plugins['…']?.options), rather than our own incoming config. It's the most
fragile part of the setup, so we're tracking less-footgun-prone alternatives in
issue #51.
3. Add the proxy (Next.js 16) / middleware (Next.js 14–15)
OAuth discovery (/.well-known/...) and bare-host MCP connectors need two
host-level URL rewrites that a Payload plugin cannot register on its own. The
plugin ships them as a ready-made request handler — wire it up with the file
convention your Next.js version uses (next to your app/ directory). Re-export
the handler, but declare config as a local literal.
Next.js 16+ — Next renamed the middleware convention to proxy. Create
src/proxy.ts:
export { mcpOAuthMiddleware as proxy } from '@brainwebuk/payload-plugin-mcp-oauth/middleware'
export const config = {
matcher: [
'/',
'/.well-known/oauth-authorization-server',
'/.well-known/oauth-protected-resource',
],
}
Next.js 14–15 — the proxy convention doesn't exist yet; create
src/middleware.ts with the same body, exported as middleware:
export { mcpOAuthMiddleware as middleware } from '@brainwebuk/payload-plugin-mcp-oauth/middleware'