# Nfs Add MCP

> Add an MCP (Model Context Protocol) entry point to an existing project scaffolded with nextjs-fullstack-starter. Use when the user wants AI clients (Claude Desktop, Cursor) to query the project's data over OAuth, mentions MCP, wants to expose tools to AI, or invokes /nfs-add-mcp. Wires up /api/mcp/route.ts inside an (mcp) route group, the MCP plugin in Better Auth (the project's OAuth provider), the .well-known OAuth discovery endpoints, a tool registry at src/server/mcp/registry.ts, one example tool, and the migration for the three OAuth tables. Requires Better Auth to be wired — if not, prompts to run /nfs-add-auth first.

- Skill: `juncoding/nfs-add-mcp` (Agent Skill)
- Install (CLI): `npx skillmds@latest add juncoding/nfs-add-mcp`
- Raw SKILL.md: https://api.skillmd.com/api/skills/juncoding/nfs-add-mcp/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: juncoding (https://skillmd.com/u/juncoding)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/juncoding/nfs-add-mcp

---


# 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

1. **Refuse if Better Auth isn't wired.** MCP uses Better Auth's `mcp` plugin as its OAuth provider. If `src/server/auth/index.ts` doesn't exist, tell the user to run `/nfs-add-auth` first.
2. **Refuse if `src/server/mcp/` already exists.** Suggest auditing what's there instead.
3. **Refuse if `src/app/(mcp)/mcp/route.ts` already exists.**

## Plan

1. Update `src/server/auth/index.ts` to add the `mcp` plugin alongside `nextCookies`.
2. Add `src/app/(mcp)/mcp/route.ts` — the POST handler that consumes the MCP transport, wrapped with `withMcpAuth`.
3. Add `src/app/.well-known/oauth-authorization-server/route.ts` + `oauth-protected-resource/route.ts` for OAuth discovery.
4. Add `src/server/mcp/registry.ts` — central tool registry.
5. Add `src/server/mcp/tools/_example.ts` — one example tool that wraps the example service.
6. Add the three Better Auth OAuth tables (`OauthApplication`, `OauthAccessToken`, `OauthConsent`) to `schema.prisma`.
7. Run `pnpm prisma migrate dev --name add_mcp_oauth`.
8. Update `CLAUDE.md`.

## File templates

### `src/server/auth/index.ts` (updated)

```ts
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`

```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`

```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`

```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:

```ts
// 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);
};
```

```ts
// 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`:

```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:

```markdown
## 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

```bash
pnpm install
pnpm prisma generate
pnpm prisma migrate dev --name add_mcp_oauth
pnpm verify
```

Smoke test with a real MCP client:

1. Run the app locally on HTTPS (tunnel if needed).
2. In Claude Desktop, add the MCP server URL.
3. Trigger the OAuth flow (login → consent → token).
4. 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` / `search` service method. Add write tools only after explicit safe-write design (per-tool dry-run mode, idempotency keys, an audit row tagged with `source: "mcp"` so staff can review what an AI client did).

