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 :
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"
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
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;
}
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;
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)import { prisma } from "@/lib/prisma";
export async function GET() {
const tasks = await prisma.task.findMany();
return Response.json(tasks);
}
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);
}
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 });
}
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;
}
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.
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);
}
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"}}}'
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 :{
"mcpServers": {
"coderoe-tasks": {
"type": "sse",
"url": "http://localhost:3000/api/mcp",
"headers": {
"Authorization": "Bearer VOTRE_SESSION_TOKEN"
}
}
}
}
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() :
// 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}` }]
};
}
);
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é !