payload-mcp-oauth

mcp
Guvenlik Denetimi
Uyari
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.

SUMMARY

OAuth 2.1 + PKCE + Dynamic Client Registration for Payload CMS MCP, so it connects to the Claude desktop and web apps.

README.md

@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'

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)

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 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).

Development

This repo is a pnpm workspace: the published plugin lives in packages/plugin, and
examples/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 + Payload
serverURL, 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 full
pnpm 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)

Sonuc bulunamadi