A Model Context Protocol server with a from-scratch OAuth 2.1 authorization server, built inside a Next.js todo app. Connect Claude (or any MCP client) to it and the full flow works end to end: dynamic client registration → PKCE authorization → token exchange → authenticated tool calls scoped to the signed-in user.
I built this to understand what actually happens when an LLM client connects to a remote MCP server — not just the tool definitions, but the entire auth handshake underneath. No auth library for the OAuth server; every endpoint is hand-rolled against the RFCs.
MCP server (src/mcp/, served at /api/mcp via Streamable HTTP):
- 6 task tools:
list_tasks,get_task,create_task,update_task,complete_task,delete_task - Zod input validation and tool annotations (
readOnlyHint,destructiveHint,idempotentHint) userIdcomes only from the validated Bearer token, never from tool arguments — an LLM cannot ask for another user's tasks- Stateless per-request handling, serverless-friendly
OAuth 2.1 authorization server (hand-rolled):
- Dynamic Client Registration — RFC 7591 (
/register) - Authorization Code + PKCE S256 (
/authorizewith a real consent page) - Server metadata discovery — RFC 8414
(
/.well-known/oauth-authorization-server) plus protected-resource metadata for MCP clients - Token endpoint with
authorization_codeandrefresh_tokengrants - Access tokens: stateless JWTs, 1h TTL, audience-bound to
/api/mcp(a token minted for this resource is rejected everywhere else) - Single-use authorization codes with 5-minute expiry; persisted refresh tokens with 30-day TTL
The app itself: Next.js 16 + React 19 + Prisma 7 + PostgreSQL, task CRUD behind a service layer that both the web UI and the MCP tools share.
Requires Node 20+ and PostgreSQL.
npm install
cp .env.example .env # set DATABASE_URL and JWT_SECRET
npx prisma migrate deploy
node scripts/seed-mock-users.mjs
npm run devThen either use the web UI at http://localhost:3000, or run the proof
scripts:
# Bearer-token MCP session: lists tools, calls them, proves per-user scoping
node scripts/mcp-test.mjs
# Full OAuth 2.1 handshake: DCR → PKCE authorize → token exchange →
# authenticated MCP call → refresh grant. Exactly what Claude does internally.
node scripts/oauth-e2e.mjs
# Against a public tunnel instead of localhost:
BASE=https://your-tunnel.example.com node scripts/oauth-e2e.mjsAdd the server URL (https://<host>/api/mcp) as a custom connector — the
client discovers the authorization server via .well-known, registers
itself, and walks the PKCE flow against the consent page.
- Auth codes are consumed atomically before validation — replay gets a 400
- PKCE S256 verified on every code exchange
- JWT
audclaim pins tokens to the MCP resource (no token passthrough) - Every service-layer query is scoped
where: { userId }— ownership is enforced below the tool layer, so a buggy tool can't leak across users - Sign-in is passwordless-by-email for local development only; wiring a real identity provider is the obvious next step for production use
The phased plan and decisions (why audience binding, why stateless access tokens + persisted refresh tokens, why the service layer owns scoping) are in docs/specs/2026-07-03-todo-mcp-server-design.md.