Créer un serveur MCP avec Next.js, Better Auth et Prisma

Par coderoe · 18 min de lecture

Next.jsMCPBetter AuthPrismaIA
Créer un serveur MCP avec Next.js, Better Auth et Prisma

1. Présentation du projet

Le Model Context Protocol (MCP) est un standard ouvert développé par Anthropic permettant aux intelligences artificielles et agents (comme Cursor, Claude Desktop, OpenAI/ChatGPT ou Antigravity) de se connecter en toute sécurité à des sources de données locales ou distantes et d'exécuter des actions via des outils (Tools), des ressources (Resources) et des prompts.

Dans le cadre d'un modèle SaaS (Software as a Service), intégrer un serveur MCP permet à vos clients ou collaborateurs d'interagir avec leurs données métier directement depuis leur LLM ou environnement de développement favori.

Dans ce tutoriel, nous allons construire un serveur MCP complet, sécurisé et déployable sur des plateformes Serverless (comme Vercel) en utilisant :

  • Next.js 16+ (avec l'App Router)
  • Better Auth (pour la gestion des sessions utilisateurs et l'authentification des clients IA via des jetons Bearer / OAuth)
  • Prisma (pour l'accès à la base de données PostgreSQL)
  • Le transport HTTP Streamable du SDK MCP (configuré en mode stateless natif pour s'adapter parfaitement aux contraintes du Serverless)

2. Création du projet Next.js

Commencez par initialiser un nouveau projet Next.js. Pour ce tutoriel, nous utiliserons pnpm :

bash
1pnpm create next-app coderoe-nextjs-mcp --typescript --tailwind --app --src-dir=false
2cd coderoe-nextjs-mcp

Configurez votre fichier .env à la racine pour y ajouter vos informations de base de données PostgreSQL (ex. Neon) et Better Auth :

env
1DATABASE_URL="postgresql://utilisateur:mot_de_passe@hote/base_de_donnees?sslmode=require"
2NEXT_PUBLIC_APP_URL="http://localhost:3000"
3BETTER_AUTH_SECRET="votre_secret_tres_long_et_securise"
4BETTER_AUTH_URL="http://localhost:3000"

3. Installation des dépendances

Installez le SDK MCP officiel, Prisma (et son client), Better Auth, ainsi que Zod pour la validation :

bash
1pnpm add @modelcontextprotocol/sdk prisma @prisma/client better-auth zod @prisma/adapter-pg pg
2pnpm add -D typescript @types/node @types/pg

4. Configuration Prisma

Initialisez Prisma dans votre projet :

bash
1npx prisma init

Modifiez le fichier prisma/schema.prisma pour déclarer vos modèles d'utilisateurs, de sessions Better Auth, de jetons OAuth, ainsi que vos modèles métier (par exemple, des tâches).

Voici la configuration exacte de prisma/schema.prisma :

prisma
1generator client {
2 provider = "prisma-client-js"
3}
4 
5datasource db {
6 provider = "postgresql"
7 url = env("DATABASE_URL")
8}
9 
10model User {
11 id String @id @default(cuid())
12 email String @unique
13 name String?
14 createdAt DateTime @default(now())
15 updatedAt DateTime @updatedAt
16 
17 sessions Session[]
18 accounts Account[]
19 tasks Task[]
20 oauthCodes OAuthCode[]
21 oauthTokens OAuthToken[]
22 emailVerified Boolean @default(false)
23 image String?
24 
25 @@map("user")
26}
27 
28model Session {
29 id String @id
30 expiresAt DateTime
31 token String @unique
32 createdAt DateTime
33 updatedAt DateTime
34 ipAddress String?
35 userAgent String?
36 userId String
37 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
38 
39 @@index([userId])
40 @@map("session")
41}
42 
43model Account {
44 id String @id
45 accountId String
46 providerId String
47 userId String
48 accessToken String?
49 refreshToken String?
50 accessTokenExpiresAt DateTime?
51 refreshTokenExpiresAt DateTime?
52 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
53 idToken String?
54 scope String?
55 password String?
56 createdAt DateTime @default(now())
57 updatedAt DateTime @updatedAt
58 
59 @@index([userId])
60 @@map("account")
61}
62 
63model Verification {
64 id String @id
65 identifier String
66 value String
67 expiresAt DateTime
68 createdAt DateTime?
69 updatedAt DateTime?
70 
71 @@index([identifier])
72 @@map("verification")
73}
74 
75model Task {
76 id String @id @default(cuid())
77 title String
78 description String?
79 completed Boolean @default(false)
80 createdAt DateTime @default(now())
81 updatedAt DateTime @updatedAt
82 userId String
83 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
84}
85 
86model OAuthCode {
87 id String @id @default(cuid())
88 code String @unique
89 userId String
90 clientId String
91 redirectUri String
92 codeChallenge String?
93 codeChallengeMethod String?
94 expiresAt DateTime
95 createdAt DateTime @default(now())
96 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
97 
98 @@index([userId])
99 @@map("oauth_code")
100}
101 
102model OAuthToken {
103 id String @id @default(cuid())
104 accessToken String @unique
105 userId String
106 clientId String
107 expiresAt DateTime
108 createdAt DateTime @default(now())
109 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
110 
111 @@index([userId])
112 @@map("oauth_token")
113}

Appliquez les migrations en base de données :

bash
1npx prisma db push

Créez le fichier d'initialisation du client Prisma dans lib/prisma.ts pour réutiliser l'instance de Prisma sans créer de connexions multiples en mode développement :

typescript
1import { PrismaClient } from "@prisma/client";
2 
3const globalForPrisma = globalThis as unknown as {
4 prisma: PrismaClient;
5};
6 
7export const prisma =
8 globalForPrisma.prisma ?? new PrismaClient();
9 
10if (process.env.NODE_ENV !== "production") {
11 globalForPrisma.prisma = prisma;
12}

5. Configuration Better Auth

Créez le fichier de configuration Better Auth dans lib/auth.ts :

typescript
1import { betterAuth } from "better-auth";
2import { prismaAdapter } from "better-auth/adapters/prisma";
3import { prisma } from "./prisma";
4 
5export const auth = betterAuth({
6 database: prismaAdapter(prisma, {
7 provider: "postgresql",
8 }),
9 emailAndPassword: {
10 enabled: true,
11 },
12});

Configurez le point d'entrée d'authentification API de Better Auth dans app/api/auth/[...better-auth]/route.ts :

typescript
1import { auth } from "@/lib/auth";
2import { toNextResponse } from "better-auth/next-js";
3 
4export const GET = async (req: Request) => {
5 return toNextResponse(await auth.handler(req));
6};
7 
8export const POST = async (req: Request) => {
9 return toNextResponse(await auth.handler(req));
10};

Créez également le client d'authentification pour le front-end dans lib/auth-client.ts :

typescript
1import { createAuthClient } from "better-auth/react";
2 
3export const authClient = createAuthClient({
4 baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
5});
6 
7export const { signIn, signOut, signUp, useSession } = authClient;

6. Création des routes métier

Pour tester notre serveur, nous devons exposer des fonctionnalités basiques de gestion des tâches pour notre SaaS dans app/api/tasks/.

1. Lister les tâches (app/api/tasks/list/route.ts)

typescript
1import { prisma } from "@/lib/prisma";
2 
3export async function GET() {
4 const tasks = await prisma.task.findMany();
5 return Response.json(tasks);
6}

2. Créer une tâche (app/api/tasks/create/route.ts)

typescript
1import { prisma } from "@/lib/prisma";
2 
3export async function POST(req: Request) {
4 const body = await req.json();
5 
6 const task = await prisma.task.create({
7 data: {
8 title: body.title,
9 description: body.description,
10 userId: body.userId,
11 },
12 });
13 
14 return Response.json(task);
15}

3. Supprimer une tâche (app/api/tasks/delete/route.ts)

typescript
1import { prisma } from "@/lib/prisma";
2 
3export async function POST(req: Request) {
4 const body = await req.json();
5 
6 await prisma.task.delete({
7 where: {
8 id: body.id,
9 },
10 });
11 
12 return Response.json({ success: true });
13}

7. Création du serveur MCP

Créez le serveur MCP dans lib/mcp.ts.

Dans un contexte Serverless (comme Vercel), il ne faut pas exporter une instance unique (singleton) du serveur MCP ni du transport, car les instances globales persistantes entrent en conflit lorsque plusieurs clients s'initialisent. Nous exportons donc une fonction d'usine (factory) createMcpServer() qui crée et configure une nouvelle instance du serveur à chaque requête HTTP :

typescript
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { z } from "zod";
3import { prisma } from "./prisma";
4 
5export function createMcpServer() {
6 const server = new McpServer({
7 name: "Task Manager",
8 version: "1.0.0",
9 });
10 
11 // Outil de test de base
12 server.tool(
13 "hello",
14 "Retourne un message de salutation simple",
15 {},
16 async () => ({
17 content: [{ type: "text", text: "Hello MCP" }],
18 })
19 );
20 
21 // Outil : Créer une tâche associée à l'utilisateur connecté
22 server.tool(
23 "create-task",
24 "Créer une tâche",
25 {
26 title: z.string(),
27 description: z.string().optional(),
28 },
29 async ({ title, description }, extra) => {
30 // extra.authInfo est injecté depuis notre route HTTP et contient l'utilisateur connecté
31 const userId = (extra?.authInfo as any)?.user?.id || "demo-user";
32
33 const task = await prisma.task.create({
34 data: {
35 title,
36 description,
37 userId,
38 },
39 });
40 
41 return {
42 content: [{ type: "text", text: JSON.stringify(task) }],
43 };
44 }
45 );
46 
47 // Outil : Lister les tâches de l'utilisateur connecté
48 server.tool(
49 "list-tasks",
50 "Liste les tâches",
51 {},
52 async (_, extra) => {
53 const userId = (extra?.authInfo as any)?.user?.id || "demo-user";
54
55 const tasks = await prisma.task.findMany({
56 where: { userId },
57 });
58 
59 return {
60 content: [{ type: "text", text: JSON.stringify(tasks) }],
61 };
62 }
63 );
64 
65 // Outil : Supprimer une tâche (si l'utilisateur connecté en est le propriétaire)
66 server.tool(
67 "delete-task",
68 "Supprime une tâche",
69 {
70 id: z.string(),
71 },
72 async ({ id }, extra) => {
73 const userId = (extra?.authInfo as any)?.user?.id || "demo-user";
74
75 const task = await prisma.task.findUnique({
76 where: { id },
77 });
78 
79 if (task && task.userId === userId) {
80 await prisma.task.delete({
81 where: { id },
82 });
83 }
84 
85 return {
86 content: [{ type: "text", text: "Task deleted" }],
87 };
88 }
89 );
90 
91 return server;
92}

8. Transport HTTP Streamable MCP

Le SDK MCP propose un transport moderne appelé WebStandardStreamableHTTPServerTransport. Pour qu'il fonctionne de manière robuste en environnement Serverless, nous devons le configurer de façon totalement stateless :

  1. Désactiver le générateur de session ID (sessionIdGenerator: undefined) pour éviter de devoir stocker et synchroniser des sessions à travers des bases Redis.
  2. Activer les réponses JSON directes pour les requêtes POST (enableJsonResponse: true), ce qui évite de maintenir des flux HTTP persistants verbeux et fragiles sur des fonctions serverless à exécution courte.

Nous créons et connectons le serveur et le transport à la volée dans notre routeur d'API unique /api/mcp.


9. Sécuriser MCP avec Better Auth

Créez le helper de session dans lib/session.ts. Il va d'abord tenter d'extraire et de valider un jeton d'accès OAuth Bearer envoyé par les clients d'intelligence artificielle externes, et se rabattre sur les cookies standards Better Auth si vous l'interrogez depuis votre interface web.

typescript
1import { auth } from "./auth";
2import { prisma } from "./prisma";
3 
4export async function requireSession(request: Request) {
5 // 1. Tenter d'extraire le jeton d'accès OAuth Bearer
6 const authHeader = request.headers.get("authorization");
7 if (authHeader && authHeader.startsWith("Bearer ")) {
8 const accessToken = authHeader.substring(7).trim();
9
10 // Rechercher le token dans la table OAuthToken
11 const oauthToken = await prisma.oAuthToken.findUnique({
12 where: { accessToken },
13 include: { user: true },
14 });
15 
16 if (oauthToken && oauthToken.expiresAt > new Date()) {
17 return {
18 user: {
19 id: oauthToken.user.id,
20 email: oauthToken.user.email,
21 name: oauthToken.user.name,
22 emailVerified: oauthToken.user.emailVerified,
23 image: oauthToken.user.image,
24 createdAt: oauthToken.user.createdAt,
25 updatedAt: oauthToken.user.updatedAt,
26 },
27 session: {
28 id: oauthToken.id,
29 userId: oauthToken.userId,
30 expiresAt: oauthToken.expiresAt,
31 token: accessToken,
32 createdAt: oauthToken.createdAt,
33 updatedAt: oauthToken.createdAt,
34 ipAddress: null,
35 userAgent: null,
36 },
37 };
38 }
39 }
40 
41 // 2. Repli vers le cookie standard de session Better Auth
42 const session = await auth.api.getSession({
43 headers: request.headers,
44 });
45 
46 if (!session) {
47 throw new Error("Unauthorized");
48 }
49 
50 return session;
51}

Créez le fichier de route d'API MCP dans app/api/mcp/route.ts.

Ce fichier configure également les en-têtes CORS nécessaires pour empêcher le navigateur web des clients d'IA (comme Perplexity) de bloquer les requêtes, et injecte le contexte d'authentification (authInfo) pour le rendre lisible par nos outils :

typescript
1import { createMcpServer } from "@/lib/mcp";
2import { requireSession } from "@/lib/session";
3import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4 
5// Helper pour injecter les en-têtes CORS nécessaires aux clients web
6function addCorsHeaders(response: Response, request: Request): Response {
7 const origin = request.headers.get("origin") || "*";
8
9 const newHeaders = new Headers(response.headers);
10 newHeaders.set("Access-Control-Allow-Origin", origin);
11 newHeaders.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE");
12 newHeaders.set("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Accept");
13 newHeaders.set("Access-Control-Expose-Headers", "Mcp-Session-Id, Mcp-Protocol-Version");
14 newHeaders.set("Access-Control-Allow-Credentials", "true");
15 
16 return new Response(response.body, {
17 status: response.status,
18 statusText: response.statusText,
19 headers: newHeaders,
20 });
21}
22 
23function getUnauthorizedResponse(request: Request) {
24 let origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
25 if (origin.endsWith("/")) {
26 origin = origin.slice(0, -1);
27 }
28 const response = new Response("Unauthorized", {
29 status: 401,
30 headers: {
31 "WWW-Authenticate": `Bearer realm="mcp", error="invalid_token", resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
32 },
33 });
34 return addCorsHeaders(response, request);
35}
36 
37export async function GET(request: Request) {
38 let session;
39 try {
40 session = await requireSession(request);
41 } catch (error) {
42 return getUnauthorizedResponse(request);
43 }
44 
45 const server = createMcpServer();
46 const transport = new WebStandardStreamableHTTPServerTransport({
47 sessionIdGenerator: undefined, // Mode Stateless
48 });
49 
50 await server.connect(transport);
51 const response = await transport.handleRequest(request, {
52 authInfo: session as any,
53 });
54 
55 return addCorsHeaders(response, request);
56}
57 
58export async function POST(request: Request) {
59 let session;
60 try {
61 session = await requireSession(request);
62 } catch (error) {
63 return getUnauthorizedResponse(request);
64 }
65 
66 const server = createMcpServer();
67 const transport = new WebStandardStreamableHTTPServerTransport({
68 sessionIdGenerator: undefined, // Mode Stateless
69 enableJsonResponse: true, // Format de réponse JSON direct
70 });
71 
72 await server.connect(transport);
73 const response = await transport.handleRequest(request, {
74 authInfo: session as any,
75 });
76 
77 return addCorsHeaders(response, request);
78}
79 
80export async function OPTIONS(request: Request) {
81 const server = createMcpServer();
82 const transport = new WebStandardStreamableHTTPServerTransport({
83 sessionIdGenerator: undefined,
84 enableJsonResponse: true,
85 });
86 
87 await server.connect(transport);
88 const response = await transport.handleRequest(request);
89 return addCorsHeaders(response, request);
90}
91 
92export async function DELETE(request: Request) {
93 let session;
94 try {
95 session = await requireSession(request);
96 } catch (error) {
97 return getUnauthorizedResponse(request);
98 }
99 
100 const server = createMcpServer();
101 const transport = new WebStandardStreamableHTTPServerTransport({
102 sessionIdGenerator: undefined,
103 enableJsonResponse: true,
104 });
105 
106 await server.connect(transport);
107 const response = await transport.handleRequest(request, {
108 authInfo: session as any,
109 });
110 
111 return addCorsHeaders(response, request);
112}

10. Tester le serveur MCP

Vous pouvez tester l'authentification et l'exécution locale de vos outils MCP.

Démarrez votre serveur de développement local :

bash
1pnpm dev

Vous pouvez ensuite simuler l'envoi d'une requête d'initialisation en utilisant curl ou en écrivant un script de test Node.js qui s'authentifie via votre API Better Auth, puis envoie le payload standard JSON-RPC :

json
1{
2 "jsonrpc": "2.0",
3 "id": 1,
4 "method": "initialize",
5 "params": {
6 "protocolVersion": "2024-11-05",
7 "capabilities": {},
8 "clientInfo": { "name": "test-client", "version": "1.0.0" }
9 }
10}

Exemple de requête avec curl :

bash
1curl -X POST http://localhost:3000/api/mcp \
2 -H "Authorization: Bearer VOTRE_SESSION_TOKEN" \
3 -H "Content-Type: application/json" \
4 -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}'

11. Connecter Cursor

Pour connecter Cursor à votre serveur MCP local :

  1. Allez dans les Settings (icône d'engrenage) de Cursor -> Features -> MCP.
  2. Cliquez sur + Add New MCP Server.
  3. Saisissez les informations suivantes :
    • Name : Coderoe Tasks
    • Type : SSE
    • URL : http://localhost:3000/api/mcp
  4. Ajoutez l'en-tête de sécurité :
    • Key : Authorization
    • Value : Bearer VOTRE_SESSION_TOKEN (que vous pouvez copier-coller depuis les cookies ou votre profil utilisateur connecté sur le site).
  5. Cliquez sur Save. Cursor se connecte et affiche instantanément les outils.

12. Connecter Claude Desktop

Pour lier Claude Desktop :

  1. Ouvrez le fichier de configuration :
    • macOS : ~/Library/Application Support/Claude/claude_desktop_config.json
    • Windows : %APPDATA%\Claude\claude_desktop_config.json
  2. Ajoutez la configuration sous mcpServers :
json
1{
2 "mcpServers": {
3 "coderoe-tasks": {
4 "type": "sse",
5 "url": "http://localhost:3000/api/mcp",
6 "headers": {
7 "Authorization": "Bearer VOTRE_SESSION_TOKEN"
8 }
9 }
10 }
11}
  1. Redémarrez Claude Desktop pour faire apparaître l'icône de prise des outils MCP.

13. Connecter Antigravity

Antigravity prend en charge nativement la connexion de serveurs MCP distants. En fournissant l'adresse HTTPS de votre API MCP de production (ex : https://votre-saas.com/api/mcp), l'agent IA d'Antigravity s'authentifiera avec vos jetons d'accès pour exécuter les outils d'ajout et d'affichage de tâches directement.


14. Ajouter Resources et Prompts MCP

En plus des Tools, vous pouvez enrichir votre serveur MCP avec :

  • Resources : Pour exposer des données en lecture seule, comme le contenu de fichiers, des statistiques ou des schémas de base de données.
  • Prompts : Pour fournir des modèles de requêtes pré-configurés que l'utilisateur peut appeler directement (ex : "Résumer mes tâches de la journée").

Vous les déclarez simplement sur l'instance server à l'intérieur de votre fonction createMcpServer() :

typescript
1// Exemple de ressource dynamique
2server.resource(
3 "user-profile",
4 "user://profile",
5 async (uri, extra) => {
6 const userId = (extra?.authInfo as any)?.user?.id;
7 // Récupérer et renvoyer le profil
8 return {
9 contents: [{ uri: uri.href, text: `Détails du profil de l'utilisateur ${userId}` }]
10 };
11 }
12);

15. Déploiement

Pour déployer sur Vercel :

  1. Créez un projet sur Vercel lié à votre dépôt Git.
  2. Ajoutez les variables d'environnement (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL).
  3. Cliquez sur Deploy.

Grâce à la nature stateless de notre configuration de transport, le serveur MCP fonctionnera sans interruption de session sur l'infrastructure éphémère de Vercel. Vous avez maintenant un serveur MCP SaaS haut de gamme et sécurisé !

Articles recommandés