payload-mcp-oauth
Health Uyari
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Gecti
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Gecti
- Permissions — No dangerous permissions requested
Bu listing icin henuz AI raporu yok.
OAuth 2.1 + PKCE + Dynamic Client Registration for Payload CMS MCP, so it connects to the Claude desktop and web apps.
@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 aftermcpPlugin(), 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 mutatingmcpOptions. 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 beforemcpPlugin().
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 ismcpPluginOptions: 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 setoverrideAuthon
the shared options object beforemcpPlugin()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. Createsrc/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; createsrc/middleware.ts with the same body, exported as middleware:
export { mcpOAuthMiddleware as middleware } from '@brainwebuk/payload-plugin-mcp-oauth/middleware'
export const config = {
matcher: [
'/',
'/.well-known/oauth-authorization-server',
'/.well-known/oauth-protected-resource',
],
}
⚠️ Don't re-export
config(e.g.export { ..., config } from '…/middleware').
Next.js parses the matcher at compile time and, as of Next 16, hard-errors
with "can't recognize the exportedconfigfield … it mustn't be reexported"
— which 500s every route in your app. The matcher must be a static literal
in yourproxy.ts/middleware.tsitself.On Next 16 a
middleware.tsstill works but logs a deprecation warning — preferproxy.ts. Migrate an existing file withnpx @next/codemod middleware-to-proxy ..
Already have a proxy/middleware? Compose it instead (shown for Next 16; on 14–15
name the file middleware.ts and the function middleware):
import type { NextRequest } from 'next/server'
import { createMcpOAuthMiddleware } from '@brainwebuk/payload-plugin-mcp-oauth/middleware'
const mcpOAuth = createMcpOAuthMiddleware() // accepts { apiRoute, mcpEndpointPath, ... }
export function proxy(request: NextRequest) {
// ...your logic first...
return mcpOAuth(request)
}
export const config = {
matcher: ['/', '/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource' /* + yours */],
}
No
next.config.tsrewrites are required — the proxy/middleware handles discovery.
4. Set environment variables
# Public HTTPS URL clients reach. Used as the OAuth issuer + in discovery metadata.
NEXT_PUBLIC_SERVER_URL=https://cms.example.com
# HMAC pepper for hashing tokens at rest — REQUIRED in production (>= 32 chars).
# Generate with: openssl rand -hex 32
PMOAUTH_TOKEN_PEPPER=<64-hex-chars>
In development a built-in insecure pepper is used if PMOAUTH_TOKEN_PEPPER is
unset (with a warning). In NODE_ENV=production the plugin throws on boot if
it is missing or shorter than 32 characters.
Keep
serverURLconsistent. The authorize/consent flow signs the user in
with a first-party Payload session cookie, so Payload'sserverURLmust be
the same public origin clients reach (the same value asNEXT_PUBLIC_SERVER_URL).
Most starters already do this viagetServerSideURL(). IfserverURLdoesn't
match the origin the browser actually uses, the consent Approve POST can
lose its session — see Troubleshooting.
5. Regenerate the admin import map (if your app uses one)
This plugin registers no custom admin components, so it doesn't require an import
map regeneration. If your app already maintains src/app/(payload)/admin/importMap.js,
regenerating it after installing is harmless and keeps it tidy:
pnpm payload generate:importmap
(Drop the src/ prefix if your app doesn't use a src directory.)
6. Apply the schema change
The plugin adds collections (oauth-clients, oauth-auth-codes, oauth-tokens,oauth-csrf-nonces). Use whichever schema workflow your app already uses — don't
mix them:
- Dev push (default for SQLite/Postgres in dev): just start the app; the new
tables are pushed on next boot. Do not runmigrate:create/migrateagainst
a push-synced dev DB — you'll get "table … already exists". - Migrations (production): run
pnpm payload migrate:createto generate a
migration that includes the new collections, thenpnpm payload migrate.
That's it — start the app and the OAuth endpoints are live, with OAuth Clients
and OAuth Tokens under the MCP group in the admin sidebar.
Connect from Claude.ai
- Settings → Connectors → Add custom connector.
- Enter your server URL (the bare host, e.g.
https://cms.example.com, works —
the middleware routes it to the MCP endpoint). - Claude.ai discovers the auth server, dynamically registers, and starts the
OAuth + PKCE handshake. - You'll be sent to your Payload admin login + a consent screen; approve to
issue a token.
Verify discovery is reachable:
curl https://cms.example.com/.well-known/oauth-protected-resource
curl https://cms.example.com/.well-known/oauth-authorization-server
Making the MCP usable for AI agents
Once connected, an agent only knows what the MCP server tells it. Tools
generated from rich collections (a page builder with nested blocks, conditional
fields, etc.) are large and non-obvious, so agents trial-and-error their way
through create* calls. Close that gap with the guidance channels@payloadcms/plugin-mcp exposes — all delivered server → agent over the
protocol, so they reach every client (Claude.ai web, Desktop, Code, and
non-Claude MCP clients):
serverOptions.instructions— a "how to use this server" string onmcpPlugin().- per-collection
description— tells the agent when/why to use a collection. - field
admin.description— flows into each tool's input schema, so the
agent reads field rules inline (e.g. "required only when …"). prompts— pre-baked, guided workflows the agent can invoke.
mcpPlugin({
serverOptions: {
serverInfo: { name: 'Author Website', version: '1.0.0' },
instructions: `
This server manages an author marketing site (pages, posts, media).
- Publish by setting "_status": "published".
- pages.hero.type is none|lowImpact|mediumImpact|highImpact; high/mediumImpact
REQUIRE hero.media (a Media id) — upload first; prefer lowImpact otherwise.
- pages.layout is an array of blocks: content, cta, mediaBlock, archive, formBlock.
- If a tool schema is large, create a minimal doc first, then add blocks with the update tool.`,
},
collections: {
pages: {
description: 'Landing/marketing pages built from a hero + layout blocks.',
enabled: { find: true, create: true, update: true },
},
},
})
Why not ship a Claude Skill for this? Skills load only from the consuming
client's own environment — an MCP server (or this npm package) cannot push a
Skill to a connecting Claude.ai/Desktop agent.instructions/promptsare the
protocol-native equivalent and reach every client automatically. (A Skill is
useful for the install experience — see below.)
Install helper for Claude Code (optional)
This repo doubles as a Claude Code plugin marketplace with an install skill that
walks Claude Code through wiring up the plugin (config, proxy, env, schema) and the
common pitfalls. In Claude Code:
/plugin marketplace add BrainWeb/payload-mcp-oauth
/plugin install payload-mcp-oauth@brainwebuk
Then ask Claude Code to "install payload-plugin-mcp-oauth" (or run/payload-mcp-oauth:install). This helps the developer installing the plugin;
because Skills are client-side it has no effect on the runtime connector agent.
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
issuer |
string |
— (required) | Public base URL; OAuth issuer + metadata base. |
mcpPluginOptions |
MCPPluginConfig |
— (required) | The same object passed to mcpPlugin(). |
userCollection |
string |
'users' |
Collection holding user accounts. |
disabled |
boolean |
false |
Turn OAuth off without uninstalling: no endpoints, no token wiring, mcpPluginOptions untouched (API-key MCP keeps working). Collections stay registered for schema consistency. Also auto-detected when mcpPluginOptions.disabled is set. |
adminAccess |
Access |
authenticated user in userCollection |
Who may view/manage the OAuth collections in the admin. See below. |
accessTokenTtlSeconds |
number |
3600 |
Access-token lifetime. |
refreshTokenTtlSeconds |
number |
86400 |
Refresh-token lifetime. |
authCodeTtlSeconds |
number |
300 |
Authorization-code lifetime. |
rateLimits |
RateLimitOptions |
{} |
Per-endpoint rate-limit overrides. |
Admin UI & access
oauth-clients and oauth-tokens render as collections under the MCP nav
group (alongside the MCP plugin's API Keys). read/update/delete are gated byadminAccess; create is always denied (clients self-register via DCR, tokens are
minted by the token endpoint). oauth-auth-codes and oauth-csrf-nonces stay
hidden and fully locked.
The default adminAccess authorises any authenticated user in youruserCollection and denies the public REST/GraphQL surface — correct for the
standard starters, where users holds only operators. If your userCollection
mixes admins with untrusted end-users, pass your own rule:
payloadMcpOAuth({
issuer,
mcpPluginOptions: mcpOptions,
adminAccess: ({ req }) => req.user?.role === 'admin',
})
Endpoints added
GET /.well-known/oauth-authorization-server, GET /.well-known/oauth-protected-resource,POST /api/oauth/register, GET /api/oauth/authorize, POST /api/oauth/consent,POST /api/oauth/token, POST /api/oauth/revoke.
OAuth tokens use the pmoauth_ prefix. The MCP handler checks the Bearer value:pmoauth_… takes the OAuth path; anything else delegates to the original API-key
handler unchanged.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Error: payloadMcpOAuth must be registered AFTER mcpPlugin() |
Plugin order — put payloadMcpOAuth() after mcpPlugin(). |
| OAuth tokens 401 but API keys work | mcpPluginOptions wasn't the same object reference (step 2). |
/.well-known/... returns the app's HTML / 404 |
proxy.ts / middleware.ts missing or its matcher doesn't include the well-known paths (step 3). |
Every route 500s; log says "can't recognize the exported config field … it mustn't be reexported" |
config was re-exported from …/middleware instead of declared as a local literal in your proxy.ts / middleware.ts (step 3). |
The "middleware" file convention is deprecated warning (Next 16) |
Rename src/middleware.ts → src/proxy.ts and export the handler as proxy (step 3). |
Consent screen renders, but Approve returns 401 access_denied / "Authentication required" |
Plugin bug in ≤ 0.3.0: the consent page sent Referrer-Policy: no-referrer, so browsers sent Origin: null on the Approve POST and Payload dropped the session (the GET render has no Origin, so it worked; the POST didn't). Fixed in 0.3.1 — upgrade. If it persists on ≥ 0.3.1, your serverURL doesn't match the origin the browser uses — check NEXT_PUBLIC_SERVER_URL (exact scheme + host, no trailing slash). |
| OAuth Clients / OAuth Tokens missing from the admin nav, or their route shows "Nothing found" | The logged-in user isn't authorised by adminAccess. By default they must belong to userCollection; for mixed-role apps pass a custom adminAccess (see Admin UI & access). |
migrate fails with "table … already exists" |
You ran migrate against a DB already created by dev push — pick one workflow (step 6). |
SQLITE_ERROR: no such column: oauth_clients_id while rebuilding payload_locked_documents_rels on pnpm dev |
SQLite push can't add the new collections' lock-FK columns to an already-pushed DB (a Payload/drizzle rebuild quirk). Fixed in 0.3.2 — the OAuth collections set lockDocuments: false, so they add no column there. On ≤ 0.3.1: add the plugin before first boot, or reset the dev DB (rm your.db*) so the schema is created fresh. |
| Boots fine in dev, throws on deploy | PMOAUTH_TOKEN_PEPPER not set in production (step 4). |
Development
This repo is a pnpm workspace: the published plugin lives in packages/plugin, andexamples/payload-app is a reference Payload 3 app (SQLite) used for integration
testing.
pnpm install # once, at the repo root
pnpm dev:example # run the reference example app (workspace source)
pnpm --filter ./packages/plugin build # build the plugin
pnpm test # unit tests across the workspace
pnpm typecheck # type-check
pnpm lint # lint
Spin up a test site from the packaged plugin
test:install:serve installs the packed plugin (pnpm pack, i.e. the real
published artifact) into a throwaway app, wires it up exactly as the docs above
describe, and leaves next dev running so you can click around — including the
OAuth admin screens.
pnpm test:install:serve # http://localhost:3000
pnpm test:install:serve -- --port 4000
pnpm test:install:serve -- --reuse # skip the rebuild, reuse the last install (fast restart)
pnpm test:install:serve -- --live # expose a public HTTPS URL (Cloudflare tunnel) for Claude.ai
--live opens a Cloudflare quick tunnel (needs cloudflared;
no login required), wires that public HTTPS URL as the OAuth issuer + PayloadserverURL, and prints steps to add the site as a Custom Connector in Claude.ai —
so you can drive a real OAuth connection against your local build.
It prints the URL and a seeded admin login ([email protected] /install-test-password-123). It is fresh-by-default — every launch reprovisions
from the freshly packed plugin so you never click around a stale build; pass--reuse once a launch has succeeded for a faster restart. First run is slow (a fullpnpm install + cold compile — allow a couple of minutes). Press Ctrl+C to stop.
Run the from-scratch install test
pnpm test:install # asserts the full install + OAuth handshake end to end
pnpm test:install -- --keep # keep the temp app on success, for debugging
This is the harness that drives packaging, the admin import map, schema push, the
OAuth + PKCE handshake, admin visibility/access gating, the disabled matrix, and
incremental install — see scripts/install-test/README.md
for the full list of what it checks and why.
License
MIT
Built and maintained by BrainWeb, a web design studio in Norfolk, UK
Yorumlar (0)
Yorum birakmak icin giris yap.
Yorum birakSonuc bulunamadi