Skip to main content
Glama
gadshushan3030

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

[![Deploy with Vercel](https://vercel.com/button)](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).