MCP OAuth Starter
README.md
# 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](https://github.com/gadshushan3030/english-coach-mcp) (single owner) and [PaceBeep](https://github.com/gadshushan3030/pacebeep) (multi-user).
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fgadshushan3030%2Fmcp-oauth-starter&project-name=mcp-oauth-starter&repository-name=mcp-oauth-starter&env=BETTER_AUTH_SECRET&envDescription=Random%2032%2B%20byte%20secret%20%28openssl%20rand%20-hex%2032%29&envLink=https%3A%2F%2Fgithub.com%2Fgadshushan3030%2Fmcp-oauth-starter%23environment&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%2C%22protocol%22%3A%22storage%22%2C%22envVarPrefix%22%3A%22DATABASE%22%7D%5D)
## 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.
## 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.
```bash
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](https://github.com/gadshushan3030/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](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues