Skip to main content
Glama
gadshushan3030

MCP OAuth Starter

MCP OAuth Starter

A Next.js starter for an app that AI assistants (ChatGPT, Claude) connect to over MCP, with a real OAuth 2.1 server, per-user data, and a Disconnect button that cuts access immediately.

The example domain is notes. Swap it for yours; the auth, OAuth and MCP plumbing stays.

Extracted from two apps running in production: English Coach (single owner) and PaceBeep (multi-user).

Deploy with Vercel

What you get

  • Accounts: email + password (Better Auth), optional "Continue with Google", account deletion.

  • OAuth 2.1 server for agents: dynamic client registration (ChatGPT registers itself), PKCE, a consent screen, discovery metadata (RFC 8414 / RFC 9728), iss in the callback (RFC 9207).

  • MCP server at /mcp (MCP TypeScript SDK v2, stateless): four example tools, reads marked readOnlyHint, writes idempotent.

  • Dashboard: your data, what the assistant wrote ("by assistant"), connected assistants with Disconnect.

  • Postgres with plain SQL migrations, applied on every Vercel build.

Related MCP server: Reflect MCP Server

How a connection works

ChatGPT                                   Your app (Next.js on Vercel)
  │  POST /mcp (no token)  ────────────►  401 + WWW-Authenticate: resource_metadata=…
  │  GET /.well-known/oauth-protected-resource/mcp   → "auth server is <url>/api/auth"
  │  GET /.well-known/oauth-authorization-server/api/auth  → endpoints
  │  POST /api/auth/oauth2/register  ─►  client_id (dynamic client registration)
  │  browser → /api/auth/oauth2/authorize (PKCE, resource=<url>/mcp)
  │             → /login → /oauth/consent → user clicks Allow
  │  POST /api/auth/oauth2/token  ────►  JWT access token (aud = <url>/mcp) + refresh token
  │  POST /mcp (Bearer)  ─────────────►  1. JWT valid? (JWKS signature, issuer, audience, expiry)
  │                                      2. consent row for (user, client) still there?
  │                                      3. tools run as that user only

Security properties

Property

How it's enforced

A token only works for this MCP server

Access tokens carry aud = <BASE_URL>/mcp; requireMcpAuth rejects any other audience. A website session cookie can't call /mcp.

Disconnect cuts access immediately

JWTs stay valid until they expire, so app/mcp/route.ts also requires the user's oauthConsent row on every request.

Disconnect also kills refresh tokens

revokeConnection deletes this user's consent, refresh tokens and access tokens in one transaction. Deleting only the consent would leave refresh tokens that can mint new access tokens.

One user's disconnect never affects another

The shared oauthClient row is never deleted, only the user's own rows.

Users only see their own rows

Every query in lib/notes.ts filters by user_id, and the MCP server is built per request for the token's sub.

Retries don't duplicate

Writes take a request_id; unique (user_id, request_id) + on conflict do nothing returns the original row on replay.

Google can't be used to take over an account

With Google enabled, new accounts come only from Google (verified email), so nobody can pre-register a password account on your email.

No secrets in the repo

.env* is ignored (only .env.example is committed). On Vercel, env vars are Sensitive.

Run locally

Requires Node 22+ and Docker.

npm install
npm run db:up                # postgres:17 on port 55434
cp .env.example .env.local   # set BETTER_AUTH_SECRET (openssl rand -hex 32)
npm run db:migrate
npm run dev

Open http://localhost:3000, create an account and add a note.

Environment

Variable

Required

Notes

DATABASE_URL

yes

Postgres. Vercel + Neon sets it (plus DATABASE_URL_UNPOOLED, used for migrations).

BETTER_AUTH_SECRET

yes

openssl rand -hex 32

BETTER_AUTH_URL

locally

Public base URL. On Vercel it defaults to the production domain.

SIGNUP_ENABLED

no

false closes registration.

GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET

no

Enables "Continue with Google". Redirect URI: <BETTER_AUTH_URL>/api/auth/callback/google.

Deploy

With the button above: Vercel copies the repo to your GitHub, creates a Neon database (env prefix DATABASE) and asks for BETTER_AUTH_SECRET. The build runs db/migrations/*.sql before next build.

By hand: import the repo in Vercel, add Storage → Neon with env prefix DATABASE, set BETTER_AUTH_SECRET, deploy. If you use a custom domain, set BETTER_AUTH_URL to it.

Connect an assistant

ChatGPT: Settings → Security and login → Developer mode, then Plugins → Add → create an MCP app with URL https://<your-app>/mcp and authentication OAuth. ChatGPT registers itself, sends you to sign in and approve, and then lists the tools.

Claude: Settings → Connectors → Add custom connector → https://<your-app>/mcp.

Make it yours

File

Change

db/migrations/0002_app.sql

Your tables. Keep user_id and unique (user_id, request_id).

lib/notes.ts

Your data functions. Every one takes the user id. Shared by the web UI and the MCP tools.

lib/mcp.ts

Your tools, and the INSTRUCTIONS the assistant reads first.

app/(main)/page.tsx, app/actions.ts

Your dashboard and server actions.

app/oauth/consent/page.tsx

What the consent screen says the assistant may do.

proxy.ts

Public pages (e.g. /privacy, /terms).

Before a public launch with Google sign-in you'll need a privacy policy and terms page. PaceBeep has an example.

Gotchas worth knowing

  • proxy.ts must skip /api/auth, /mcp and /.well-known. Otherwise the discovery requests get redirected to /login and the assistant can't find the auth server.

  • The issuer has a path (/api/auth), so authorization server metadata lives at /.well-known/oauth-authorization-server/api/auth (RFC 8414 path insertion), not at the root.

  • Don't set legacy: "reject" in createMcpHandler. ChatGPT speaks protocol 2025-06-18.

  • Revoking a JWT means checking state per request. That's one indexed query on /mcp, and it's what makes Disconnect real.

  • Vercel Sensitive env vars can't be pulled locally (vercel env pull returns empty values), so migrations run in the vercel-build script, where they're available.

  • Use clock_timestamp() for created_at when several rows are inserted in one transaction: now() is the same for all of them and they lose their order.

Built with

Next.js 16, TypeScript, Tailwind 4, Better Auth (+ @better-auth/mcp), MCP TypeScript SDK v2, Postgres (Neon), Vercel.

License

MIT – see LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI assistants to search and read your Evernote notes using natural language queries through secure OAuth authentication. Supports containerized deployment with production-ready security features.
    10 npm
    56
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables AI assistants to create notes, save links, append to daily notes, and manage knowledge graphs in Reflect via the Reflect Notes API, with OAuth2 authentication and token management.
    3
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    Provides AI assistants with secure access to Notion workspaces, enabling page management, search, block operations, and user management.
    2,648 npm
    MIT