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.

## Architecture

Two front doors into one app: people come in with a session cookie, assistants with a Bearer token. Both reach the same data layer, and both are authenticated by the same Better Auth server.

![Architecture: people and assistants, two doors into one Next.js app over Postgres](docs/architecture.svg)

## How an assistant connects

Nothing is configured by hand on the assistant's side: it discovers the auth server, registers itself, and sends you to sign in and approve.

![Sequence: discover, register, authorize with PKCE, token, tool calls](docs/oauth-flow.svg)

## 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 still mint access tokens, which pass again as soon as you reconnect the same assistant. |
| 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. |

### Why Disconnect works immediately

A JWT is checked by signature alone, so on its own it stays valid until it expires, and a refresh token keeps minting new ones. `/mcp` therefore also looks up the consent row on every call:

![Timelines: JWT-only keeps working after Disconnect; with the consent check the next call gets 401](docs/disconnect.svg)

### Why retries never duplicate

Assistants retry. A response lost on the network must not turn into two notes:

![Sequence: a lost response, a retry with the same request_id, one row](docs/retries.svg)

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