Fixes the two reported bugs and the review sweep around them. Full detail in #76.
Upgrading — three behaviour changes
1. Grants are resolved against your live config on every request. A scoped token names a fixed set and can now only shrink: one issued while posts:delete was enabled loses it as soon as you disable it, instead of keeping it until expiry. A no-scope token means "everything enabled now" and tracks your config in both directions, as before.
2. A copied mcpPluginOptions now throws at boot (MCP_OPTIONS_NOT_SHARED). Passing a spread or a fresh literal to one plugin and the original to the other was always broken — OAuth tokens 401 while API keys keep working — but it booted. It now fails loudly, naming the mistake. If you hit this on upgrade, assign the options to a const and pass that same const to both mcpPlugin() and payloadMcpOAuth().
3. The login redirect honours routes.admin and admin.routes.login. If you customise either, unauthenticated users mid-flow now reach your real login page rather than a hardcoded /admin/login. A new loginPath option overrides it for sign-in pages outside the admin panel.
Fixes
- #75 — tokens issued without a scope recorded
capabilities: {}, which read as "no permissions" to anything inspecting the row while requests ran with full access via a fallback. Claude.ai's Custom Connector completes the flow without a scope, so this affected every such token. The row now records the real grant;scopeToCapabilitiesreturns a discriminated result so "no scope requested" and "invalid scope" can no longer be confused. Existing tokens keep working — no migration, no backfill. - #71 — a
http://localhostissuer no longer blocks a localnext build.next build/next startsetNODE_ENV=productionwhether or not you are deploying, and a loopback URL is unreachable off the machine. Non-loopback http issuers are still refused; a loopback one in production now warns, in case it is an unsetSERVER_URLon a real deployment. - Scoped tokens previously used their stored capabilities verbatim, so a grant could outlive the permission that justified it.
issis now set on error and deny authorization responses, not just success (RFC 9207 §2).- A malformed
oauth-clientsrow returned a 500 instead ofinvalid_redirect_uri.
Additions
scopes_supportedin both discovery documents, derived from yourmcpPluginOptions— clients can now discover what to request. Also advertisesauthorization_response_iss_parameter_supported.loginPathoption.- The plugin registers slug
plugin-mcp-oauthwithoptionsand aRegisteredPluginsaugmentation, for cross-plugin discovery.
Docs
Scopes are documented for the first time. The adminAccess default was described as "any authenticated user in your userCollection", omitting the role check — if your operators carry a role other than admin, they are locked out of the OAuth screens and now the README says so. threat-model.md T2/E1 described the pre-narrowing design and are rewritten.
Also
#50 endpoints are derived rather than mutated in place. #51 the copied-options detection above, plus a record of the two alternatives that are structurally impossible. #52 plugin slug/order/options set directly — deliberately not via definePlugin, which only exists from payload 3.83.0 while the peer range is ^3.0.0. #53 closed: Payload's KV store has no compare-and-set, so moving auth codes to it would make single-use redemption a race.
Verified with 434 unit tests and the from-scratch packaged install test (31/31), which packs the tarball into a fresh Payload app and runs the full OAuth handshake.