If you've added the official @payloadcms/plugin-mcp to a Payload project, you've probably noticed it works beautifully with Claude Code and not at all with the Claude desktop or web apps. The plugin is solid. The problem is how those two clients expect to connect.
This is the post we wish we'd found when we hit that wall. It explains why the gap exists, and it points at the small plugin we wrote to close it.
The actual problem
The official MCP plugin authenticates with an API key. You generate a key, you hand it to the client, the client sends it on every request. Claude Code is happy with that, because you configure it locally and you control the file the key sits in.
The Claude desktop and web apps don't work that way. When you add a custom connector there, the app expects to log in to your server: discover where the auth lives, register itself, send you off to approve the connection, and come back holding a token. That's OAuth, and the API-key plugin doesn't speak it. So the connector either refuses to set up or sits there unauthenticated.
There's no flag to flip. The two clients want genuinely different things, and the official plugin only ships one of them.
What we wanted
The same Payload server we already query from Claude Code, available as a proper custom connector in the desktop and web apps, with a real login flow rather than a key copied into a settings box. Crucially, without breaking the API-key path that was already working.
The plugin
So we built the missing half and published it: @brainwebuk/payload-plugin-mcp-oauth.
It's purely additive. It wraps the MCP endpoint handler and adds the OAuth machinery the desktop and web apps need, while leaving the original API-key route completely untouched. Anything already connected to your server keeps working exactly as it did. The handler simply checks the bearer token: a pmoauth_ token takes the OAuth path, anything else falls through to the original API-key handler unchanged.
What it adds:
- OAuth 2.1 authorization-code flow with PKCE (S256 only). The modern standard, done properly.
- Dynamic Client Registration (RFC 7591), so Claude.ai registers itself with no manual client setup at your end.
- Discovery documents (RFC 8414 and RFC 9728) at the
.well-knownpaths, so the client finds the auth server on its own. - Tokens hashed at rest with HMAC-SHA-256, plus refresh and revocation.
- Admin views for the tokens you've issued and the clients that have registered.
Before you start
The plugin needs Payload 3, @payloadcms/plugin-mcp 3 (tested against 3.85.0), and Node 20 or newer. The proxy/middleware export supports Next.js 14, 15, and 16. If you're on the official MCP plugin already, you're almost certainly fine.
How it goes together
Six steps, none of them long, but two have a sharp edge worth flagging.
1. Install the package.
pnpm add @brainwebuk/payload-plugin-mcp-oauth
2. Register it after mcpPlugin(), with the same options object. Two things matter here, and both will catch you out if you miss them. First, order: the plugin throws on boot if it's registered before mcpPlugin(). Second, and this is the one that's genuinely hard to spot: pass the exact same options object to both plugins. The plugin installs its token-validation hook by mutating that object, so a fresh object or a spread copy means OAuth tokens silently fail to authenticate. Because the API-key path keeps working, it's easy to miss. Assign the options to one const and reuse that reference in both calls.
const mcpOptions: MCPPluginConfig = {
collections: {
users: { enabled: { find: true, update: true } },
media: { enabled: { find: true, create: true } },
},
}
export default buildConfig({
plugins: [
mcpPlugin(mcpOptions),
payloadMcpOAuth({
issuer: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
mcpPluginOptions: mcpOptions, // the same object, not a copy
}),
],
})
3. Wire up the proxy or middleware. Discovery and bare-host connector URLs need two host-level rewrites that a Payload plugin can't register on its own, so the package ships a ready-made handler. On Next.js 16 it goes in proxy.ts, exported as proxy; on 14 and 15 it's middleware.ts with the same body, exported as middleware. (Next 16 renamed the convention from middleware to proxy. An old middleware.ts still works on 16 but logs a deprecation warning; npx @next/codemod middleware-to-proxy . migrates it.)
The catch worth burning into memory: declare the config matcher as a local literal in that file. Don't re-export config from the package. As of Next 16, re-exporting it hard-errors and 500s every route in your app, which is a memorable afternoon. If you already have a middleware of your own, the package exports a createMcpOAuthMiddleware() factory so you can compose the two rather than choosing between them; the README has the snippet.
4. Set your environment variables. A public server URL for the issuer, and PMOAUTH_TOKEN_PEPPER for hashing tokens at rest (generate one with openssl rand -hex 32). In development the plugin falls back to an insecure pepper with a warning; in production it throws on boot if the pepper is missing or under 32 characters, which is the correct place to find out.
5. Regenerate the admin import map. The plugin registers two admin views, and Payload resolves those through a generated map, so run pnpm payload generate:importmap and commit the updated importMap.js.
6. Run your migrations. The plugin adds three collections (oauth-clients, oauth-auth-codes, oauth-tokens), so apply your usual schema step and you're done.
Then in Claude: Settings, Connectors, Add custom connector, enter your server URL. The app handles discovery, registers itself, runs the handshake, sends you to your Payload admin login and a consent screen, and you're connected.
What we'd tell anyone hitting the same wall
If the only thing standing between you and a working desktop connector is the auth flow, you don't need to fork the official plugin or rebuild the MCP layer. The endpoint is fine. You just need the OAuth half bolted on alongside it, additively, so the key-based path you already trust stays exactly where it is.
That's the whole design philosophy of the thing, and it's the same one that runs through everything the studio builds: solve the actual problem, change as little as possible, and leave the working parts working.
pnpm add @brainwebuk/payload-plugin-mcp-oauth
The package is on npm, the source is on GitHub under an MIT licence, and there's an INSTALL_FOR_AGENTS.md in the package if you'd rather point a coding agent at it than follow the steps by hand. The README also carries a troubleshooting table mapping each likely symptom (the silent 401, the every-route 500, the missing well-known documents) back to the step that fixes it.
