Add an MCP route to an existing scaffold
Use when a project from nextjs-fullstack-starter needs an MCP entry point so AI clients can call services over OAuth.
Pre-flight checks
- Refuse if Better Auth isn't wired. MCP uses Better Auth's
mcpplugin as its OAuth provider. Ifsrc/server/auth/index.tsdoesn't exist, tell the user to run/nfs-add-authfirst. - Refuse if
src/server/mcp/already exists. Suggest auditing what's there instead. - Refuse if
src/app/(mcp)/mcp/route.tsalready exists.
Plan
- Update
src/server/auth/index.tsto add themcpplugin alongsidenextCookies. - Add
src/app/(mcp)/mcp/route.ts— the POST handler that consumes the MCP transport, wrapped withwithMcpAuth. - Add
src/app/.well-known/oauth-authorization-server/route.ts+oauth-protected-resource/route.tsfor OAuth discovery. - Add
src/server/mcp/registry.ts— central tool registry. - Add
src/server/mcp/tools/_example.ts— one example tool that wraps the example service. - Add the three Better Auth OAuth tables (
OauthApplication,OauthAccessToken,OauthConsent) toschema.prisma. - Run
pnpm prisma migrate dev --name add_mcp_oauth. - Update
CLAUDE.md.
File templates
src/server/auth/index.ts (updated)
import "server-only";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { mcp } from "better-auth/plugins";
import { db } from "@/server/db/client";
import { env } from "@/env";
export const auth = betterAuth({
database: prismaAdapter(db, { provider: "postgresql" }),
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
emailAndPassword: { enabled: true, autoSignIn: true },
plugins: [
nextCookies(),
mcp({
loginPage: "/login",
}),
],
});
export type Session = typeof auth.$Infer.Session;
src/app/(mcp)/mcp/route.ts
import { withMcpAuth } from "better-auth/plugins";
import { auth } from "@/server/auth";
import { mcpRegistry } from "@/server/mcp/registry";
export const POST = withMcpAuth(auth, async (req, session) => {
return mcpRegistry.handle(req, { userId: session.user.id });
});
src/server/mcp/registry.ts
import "server-only";
import { exampleSearchTool } from "./tools/_example";
const TOOLS = {
example_search: exampleSearchTool,
} as const;
type ToolName = keyof typeof TOOLS;
export const mcpRegistry = {
list() {
return Object.entries(TOOLS).map(([name, tool]) => ({
name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
},
async handle(req: Request, ctx: { userId: string }) {
const body = await req.json();
if (body.method === "tools/list") {
return Response.json({ tools: this.list() });
}
if (body.method === "tools/call") {
const { name, arguments: args } = body.params;
const tool = TOOLS[name as ToolName];
if (!tool) {
return Response.json({ error: { code: -32601, message: `Unknown tool: ${name}` } });
}
const result = await tool.run(ctx.userId, args);
return Response.json({ content: [{ type: "text", text: JSON.stringify(result) }] });
}
return Response.json({ error: { code: -32601, message: "Unsupported method" } });
},
};
src/server/mcp/tools/_example.ts
import "server-only";
import { z } from "zod";
import { exampleService } from "@/server/modules/_example/_example.service";
const InputSchema = z.object({
q: z.string().optional(),
limit: z.number().int().min(1).max(50).optional(),
});
export const exampleSearchTool = {
description: "Search examples by query string. Returns up to `limit` results.",
inputSchema: {
type: "object",
properties: {
q: { type: "string", description: "Search query" },
limit: { type: "number", description: "Max results (default 20)" },
},
},
async run(userId: string, args: unknown) {
const input = InputSchema.parse(args);
return exampleService.list(userId, input);
},
};
The tool wraps the existing service. Permissions and audit are inherited unchanged — the service still calls requirePermission(userId, "example:read").
.well-known OAuth discovery routes
Better Auth's MCP plugin exposes the discovery metadata through helper handlers. The shape:
// src/app/.well-known/oauth-authorization-server/route.ts
import { auth } from "@/server/auth";
export const GET = async () => {
const metadata = await auth.api.getMcpDiscoveryMetadata();
return Response.json(metadata);
};
// src/app/.well-known/oauth-protected-resource/route.ts
import { auth } from "@/server/auth";
export const GET = async () => {
const metadata = await auth.api.getMcpProtectedResourceMetadata();
return Response.json(metadata);
};
If the Better Auth MCP API surface is different in your installed version, consult the upstream docs and adapt — the principle is "expose the metadata Better Auth provides."
Prisma — three OAuth tables
Add to schema.prisma:
model OauthApplication {
id String @id @default(cuid())
name String
clientId String @unique
clientSecret String?
redirectURLs String
type String
metadata String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OauthAccessToken {
id String @id @default(cuid())
accessToken String @unique
refreshToken String? @unique
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
clientId String
userId String
scopes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([clientId])
}
model OauthConsent {
id String @id @default(cuid())
clientId String
userId String
scopes String
consentGiven Boolean
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([clientId, userId])
}
Migrate: pnpm prisma migrate dev --name add_mcp_oauth.
Update CLAUDE.md
Add a section:
## MCP
Read-only MCP endpoint at `POST /mcp`, gated by OAuth via Better Auth's `mcp` plugin.
- Tools live under `src/server/mcp/tools/`, registered in `src/server/mcp/registry.ts`.
- Each tool wraps an existing service method — permissions + audit inherited unchanged.
- Local dev caveat: Claude Desktop only accepts HTTPS URLs, so testing against `http://localhost` requires a tunnel (cloudflared, ngrok). Production / staging serve HTTPS natively.
To add a new tool:
1. Write the tool file under `src/server/mcp/tools/<name>.ts`. Wrap an existing service.
2. Register it in `src/server/mcp/registry.ts`.
3. No DB migration needed.
Verification
pnpm install
pnpm prisma generate
pnpm prisma migrate dev --name add_mcp_oauth
pnpm verify
Smoke test with a real MCP client:
- Run the app locally on HTTPS (tunnel if needed).
- In Claude Desktop, add the MCP server URL.
- Trigger the OAuth flow (login → consent → token).
- Ask Claude to call
example_search.
Anti-patterns to refuse
- Adding MCP tools that don't wrap services. Inline DB queries in tools break the permission/audit invariants. Always wrap a service.
- Tools that take userId from the args. The userId comes from
withMcpAuth's session, not the client. Don't trust client-supplied identity. - Tools without input validation. Use Zod on every tool's input — the MCP client may be a different model than the one you tested with.
- Mutating tools without explicit user confirmation. For Phase 1, ship read-only — every tool calls a
list/findById/searchservice method. Add write tools only after explicit safe-write design (per-tool dry-run mode, idempotency keys, an audit row tagged withsource: "mcp"so staff can review what an AI client did).