npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@brainwebuk/payload-plugin-mcp-oauth

v0.3.6

Published

OAuth 2.1 + PKCE + Dynamic Client Registration for [`@payloadcms/plugin-mcp`](https://www.npmjs.com/package/@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.

Readme

@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 this npm package) — 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'

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 exported config field … it mustn't be reexported" — which 500s every route in your app. The matcher must be a static literal in your proxy.ts / middleware.ts itself.

On Next 16 a middleware.ts still works but logs a deprecation warning — prefer proxy.ts. Migrate an existing file with npx @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.ts rewrites 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 serverURL consistent. The authorize/consent flow signs the user in with a first-party Payload session cookie, so Payload's serverURL must be the same public origin clients reach (the same value as NEXT_PUBLIC_SERVER_URL). Most starters already do this via getServerSideURL(). If serverURL doesn'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 run migrate:create/migrate against a push-synced dev DB — you'll get "table … already exists".
  • Migrations (production): run pnpm payload migrate:create to generate a migration that includes the new collections, then pnpm 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

  1. Settings → ConnectorsAdd custom connector.
  2. Enter your server URL (the bare host, e.g. https://cms.example.com, works — the middleware routes it to the MCP endpoint).
  3. Claude.ai discovers the auth server, dynamically registers, and starts the OAuth + PKCE handshake.
  4. 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 on mcpPlugin().
  • 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/prompts are 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)

The project 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 by adminAccess; 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 your userCollection 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.tssrc/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). |


License

MIT