Par coderoe · 18 min de lecture

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 :
Commencez par initialiser un nouveau projet Next.js. Pour ce tutoriel, nous utiliserons pnpm :
1pnpm create next-app coderoe-nextjs-mcp --typescript --tailwind --app --src-dir=false2cd coderoe-nextjs-mcpConfigurez votre fichier .env à la racine pour y ajouter vos informations de base de données PostgreSQL (ex. Neon) et Better Auth :
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"Installez le SDK MCP officiel, Prisma (et son client), Better Auth, ainsi que Zod pour la validation :
1pnpm add @modelcontextprotocol/sdk prisma @prisma/client better-auth zod @prisma/adapter-pg pg2pnpm add -D typescript @types/node @types/pgInitialisez Prisma dans votre projet :
1npx prisma initModifiez 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 :
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 @unique13 name String?14 createdAt DateTime @default(now())15 updatedAt DateTime @updatedAt16 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 @id30 expiresAt DateTime31 token String @unique32 createdAt DateTime33 updatedAt DateTime34 ipAddress String?35 userAgent String?36 userId String37 user User @relation(fields: [userId], references: [id], onDelete: Cascade)38 39 @@index([userId])40 @@map("session")41}42 43model Account {44 id String @id45 accountId String46 providerId String47 userId String48 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 @updatedAt58 59 @@index([userId])60 @@map("account")61}62 63model Verification {64 id String @id65 identifier String66 value String67 expiresAt DateTime68 createdAt DateTime?69 updatedAt DateTime?70 71 @@index([identifier])72 @@map("verification")73}74 75model Task {76 id String @id @default(cuid())77 title String78 description String?79 completed Boolean @default(false)80 createdAt DateTime @default(now())81 updatedAt DateTime @updatedAt82 userId String83 user User @relation(fields: [userId], references: [id], onDelete: Cascade)84}85 86model OAuthCode {87 id String @id @default(cuid())88 code String @unique89 userId String90 clientId String91 redirectUri String92 codeChallenge String?93 codeChallengeMethod String?94 expiresAt DateTime95 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 @unique105 userId String106 clientId String107 expiresAt DateTime108 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 :
1npx prisma db pushCré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 :
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}Créez le fichier de configuration Better Auth dans lib/auth.ts :
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 :
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 :
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;Pour tester notre serveur, nous devons exposer des fonctionnalités basiques de gestion des tâches pour notre SaaS dans app/api/tasks/.
app/api/tasks/list/route.ts)1import { prisma } from "@/lib/prisma";2 3export async function GET() {4 const tasks = await prisma.task.findMany();5 return Response.json(tasks);6}app/api/tasks/create/route.ts)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}app/api/tasks/delete/route.ts)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}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 :
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 base12 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}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 :
sessionIdGenerator: undefined) pour éviter de devoir stocker et synchroniser des sessions à travers des bases Redis.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.
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.
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 Bearer6 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 OAuthToken11 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 Auth42 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 :
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 web6function 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 Stateless48 });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 Stateless69 enableJsonResponse: true, // Format de réponse JSON direct70 });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}Vous pouvez tester l'authentification et l'exécution locale de vos outils MCP.
Démarrez votre serveur de développement local :
1pnpm devVous 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 :
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 :
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"}}}'Pour connecter Cursor à votre serveur MCP local :
Coderoe TasksSSEhttp://localhost:3000/api/mcpAuthorizationBearer VOTRE_SESSION_TOKEN (que vous pouvez copier-coller depuis les cookies ou votre profil utilisateur connecté sur le site).Pour lier Claude Desktop :
~/Library/Application Support/Claude/claude_desktop_config.json%APPDATA%\Claude\claude_desktop_config.jsonmcpServers :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}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.
En plus des Tools, vous pouvez enrichir votre serveur MCP avec :
Vous les déclarez simplement sur l'instance server à l'intérieur de votre fonction createMcpServer() :
1// Exemple de ressource dynamique2server.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 profil8 return {9 contents: [{ uri: uri.href, text: `Détails du profil de l'utilisateur ${userId}` }]10 };11 }12);Pour déployer sur Vercel :
DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL).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é !