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 :

pnpm create next-app coderoe-nextjs-mcp --typescript --tailwind --app --src-dir=false
cd 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 :

DATABASE_URL="postgresql://utilisateur:mot_de_passe@hote/base_de_donnees?sslmode=require"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
BETTER_AUTH_SECRET="votre_secret_tres_long_et_securise"
BETTER_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 :

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

4. Configuration Prisma

Initialisez Prisma dans votre projet :

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

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id            String       @id @default(cuid())
  email         String       @unique
  name          String?
  createdAt     DateTime     @default(now())
  updatedAt     DateTime     @updatedAt

  sessions      Session[]
  accounts      Account[]
  tasks         Task[]
  oauthCodes    OAuthCode[]
  oauthTokens   OAuthToken[]
  emailVerified Boolean      @default(false)
  image         String?

  @@map("user")
}

model Session {
  id        String   @id
  expiresAt DateTime
  token     String   @unique
  createdAt DateTime
  updatedAt DateTime
  ipAddress String?
  userAgent String?
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("session")
}

model Account {
  id                    String    @id
  accountId             String
  providerId            String
  userId                String
  accessToken           String?
  refreshToken          String?
  accessTokenExpiresAt  DateTime?
  refreshTokenExpiresAt DateTime?
  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  idToken               String?
  scope                 String?
  password              String?
  createdAt             DateTime  @default(now())
  updatedAt             DateTime  @updatedAt

  @@index([userId])
  @@map("account")
}

model Verification {
  id         String    @id
  identifier String
  value      String
  expiresAt  DateTime
  createdAt  DateTime?
  updatedAt  DateTime?

  @@index([identifier])
  @@map("verification")
}

model Task {
  id          String   @id @default(cuid())
  title       String
  description String?
  completed   Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  userId      String
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model OAuthCode {
  id                  String   @id @default(cuid())
  code                String   @unique
  userId              String
  clientId            String
  redirectUri         String
  codeChallenge       String?
  codeChallengeMethod String?
  expiresAt           DateTime
  createdAt           DateTime @default(now())
  user                User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("oauth_code")
}

model OAuthToken {
  id          String   @id @default(cuid())
  accessToken String   @unique
  userId      String
  clientId    String
  expiresAt   DateTime
  createdAt   DateTime @default(now())
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("oauth_token")
}

Appliquez les migrations en base de données :

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

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient;
};

export const prisma =
  globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

5. Configuration Better Auth

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

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./prisma";

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  emailAndPassword: {
    enabled: true,
  },
});

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

import { auth } from "@/lib/auth";
import { toNextResponse } from "better-auth/next-js";

export const GET = async (req: Request) => {
  return toNextResponse(await auth.handler(req));
};

export const POST = async (req: Request) => {
  return toNextResponse(await auth.handler(req));
};

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

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});

export 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)

import { prisma } from "@/lib/prisma";

export async function GET() {
  const tasks = await prisma.task.findMany();
  return Response.json(tasks);
}

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

import { prisma } from "@/lib/prisma";

export async function POST(req: Request) {
  const body = await req.json();

  const task = await prisma.task.create({
    data: {
      title: body.title,
      description: body.description,
      userId: body.userId,
    },
  });

  return Response.json(task);
}

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

import { prisma } from "@/lib/prisma";

export async function POST(req: Request) {
  const body = await req.json();

  await prisma.task.delete({
    where: {
      id: body.id,
    },
  });

  return Response.json({ success: true });
}

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 :

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { prisma } from "./prisma";

export function createMcpServer() {
  const server = new McpServer({
    name: "Task Manager",
    version: "1.0.0",
  });

  // Outil de test de base
  server.tool(
    "hello",
    "Retourne un message de salutation simple",
    {},
    async () => ({
      content: [{ type: "text", text: "Hello MCP" }],
    })
  );

  // Outil : Créer une tâche associée à l'utilisateur connecté
  server.tool(
    "create-task",
    "Créer une tâche",
    {
      title: z.string(),
      description: z.string().optional(),
    },
    async ({ title, description }, extra) => {
      // extra.authInfo est injecté depuis notre route HTTP et contient l'utilisateur connecté
      const userId = (extra?.authInfo as any)?.user?.id || "demo-user";
      
      const task = await prisma.task.create({
        data: {
          title,
          description,
          userId,
        },
      });

      return {
        content: [{ type: "text", text: JSON.stringify(task) }],
      };
    }
  );

  // Outil : Lister les tâches de l'utilisateur connecté
  server.tool(
    "list-tasks",
    "Liste les tâches",
    {},
    async (_, extra) => {
      const userId = (extra?.authInfo as any)?.user?.id || "demo-user";
      
      const tasks = await prisma.task.findMany({
        where: { userId },
      });

      return {
        content: [{ type: "text", text: JSON.stringify(tasks) }],
      };
    }
  );

  // Outil : Supprimer une tâche (si l'utilisateur connecté en est le propriétaire)
  server.tool(
    "delete-task",
    "Supprime une tâche",
    {
      id: z.string(),
    },
    async ({ id }, extra) => {
      const userId = (extra?.authInfo as any)?.user?.id || "demo-user";
      
      const task = await prisma.task.findUnique({
        where: { id },
      });

      if (task && task.userId === userId) {
        await prisma.task.delete({
          where: { id },
        });
      }

      return {
        content: [{ type: "text", text: "Task deleted" }],
      };
    }
  );

  return server;
}

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.

import { auth } from "./auth";
import { prisma } from "./prisma";

export async function requireSession(request: Request) {
  // 1. Tenter d'extraire le jeton d'accès OAuth Bearer
  const authHeader = request.headers.get("authorization");
  if (authHeader && authHeader.startsWith("Bearer ")) {
    const accessToken = authHeader.substring(7).trim();
    
    // Rechercher le token dans la table OAuthToken
    const oauthToken = await prisma.oAuthToken.findUnique({
      where: { accessToken },
      include: { user: true },
    });

    if (oauthToken && oauthToken.expiresAt > new Date()) {
      return {
        user: {
          id: oauthToken.user.id,
          email: oauthToken.user.email,
          name: oauthToken.user.name,
          emailVerified: oauthToken.user.emailVerified,
          image: oauthToken.user.image,
          createdAt: oauthToken.user.createdAt,
          updatedAt: oauthToken.user.updatedAt,
        },
        session: {
          id: oauthToken.id,
          userId: oauthToken.userId,
          expiresAt: oauthToken.expiresAt,
          token: accessToken,
          createdAt: oauthToken.createdAt,
          updatedAt: oauthToken.createdAt,
          ipAddress: null,
          userAgent: null,
        },
      };
    }
  }

  // 2. Repli vers le cookie standard de session Better Auth
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session) {
    throw new Error("Unauthorized");
  }

  return session;
}

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 :

import { createMcpServer } from "@/lib/mcp";
import { requireSession } from "@/lib/session";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";

// Helper pour injecter les en-têtes CORS nécessaires aux clients web
function addCorsHeaders(response: Response, request: Request): Response {
  const origin = request.headers.get("origin") || "*";
  
  const newHeaders = new Headers(response.headers);
  newHeaders.set("Access-Control-Allow-Origin", origin);
  newHeaders.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE");
  newHeaders.set("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Accept");
  newHeaders.set("Access-Control-Expose-Headers", "Mcp-Session-Id, Mcp-Protocol-Version");
  newHeaders.set("Access-Control-Allow-Credentials", "true");

  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: newHeaders,
  });
}

function getUnauthorizedResponse(request: Request) {
  let origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
  if (origin.endsWith("/")) {
    origin = origin.slice(0, -1);
  }
  const response = new Response("Unauthorized", {
    status: 401,
    headers: {
      "WWW-Authenticate": `Bearer realm="mcp", error="invalid_token", resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
    },
  });
  return addCorsHeaders(response, request);
}

export async function GET(request: Request) {
  let session;
  try {
    session = await requireSession(request);
  } catch (error) {
    return getUnauthorizedResponse(request);
  }

  const server = createMcpServer();
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // Mode Stateless
  });

  await server.connect(transport);
  const response = await transport.handleRequest(request, {
    authInfo: session as any,
  });

  return addCorsHeaders(response, request);
}

export async function POST(request: Request) {
  let session;
  try {
    session = await requireSession(request);
  } catch (error) {
    return getUnauthorizedResponse(request);
  }

  const server = createMcpServer();
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // Mode Stateless
    enableJsonResponse: true,       // Format de réponse JSON direct
  });

  await server.connect(transport);
  const response = await transport.handleRequest(request, {
    authInfo: session as any,
  });

  return addCorsHeaders(response, request);
}

export async function OPTIONS(request: Request) {
  const server = createMcpServer();
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });

  await server.connect(transport);
  const response = await transport.handleRequest(request);
  return addCorsHeaders(response, request);
}

export async function DELETE(request: Request) {
  let session;
  try {
    session = await requireSession(request);
  } catch (error) {
    return getUnauthorizedResponse(request);
  }

  const server = createMcpServer();
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });

  await server.connect(transport);
  const response = await transport.handleRequest(request, {
    authInfo: session as any,
  });

  return addCorsHeaders(response, request);
}

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 :

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

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {},
    "clientInfo": { "name": "test-client", "version": "1.0.0" }
  }
}

Exemple de requête avec curl :

curl -X POST http://localhost:3000/api/mcp \
  -H "Authorization: Bearer VOTRE_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -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 :
{
  "mcpServers": {
    "coderoe-tasks": {
      "type": "sse",
      "url": "http://localhost:3000/api/mcp",
      "headers": {
        "Authorization": "Bearer VOTRE_SESSION_TOKEN"
      }
    }
  }
}
  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() :

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

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