Construisez un serveur Model Context Protocol en TypeScript : tools, resources, prompts, transports stdio/SSE et bonnes pratiques pour la production.
Pourquoi MCP existe
Avant MCP, chaque intégration entre un LLM et un service externe était ad-hoc : un script custom pour brancher Claude sur Postgres, un autre pour Notion, un autre pour Jira. Le code n'était pas partageable, chaque client (Claude Desktop, agent, IDE) devait réinventer la roue.
Model Context Protocol, publié par Anthropic en open source fin 2024, joue pour les LLM le rôle qu'a joué Language Server Protocol pour les éditeurs : un protocole standard sur lequel tous les acteurs peuvent s'aligner. Vous écrivez une fois un serveur MCP qui expose vos tools/resources/prompts, et il fonctionne avec Claude Desktop, Claude Code, Cursor, Continue.dev, et tous les clients à venir.
Les trois primitives MCP
| Primitive | Rôle | Exemple concret | Contrôlé par |
|---|---|---|---|
tools |
Fonctions appelables | create_issue, run_sql_query |
Modèle (Claude décide d'appeler) |
resources |
Données lisibles (URI) | file:///docs/api.md, db://users/42 |
Application/utilisateur (sélection) |
prompts |
Templates de prompts | code-review, summarize-pr |
Utilisateur (slash commands) |
Architecture client / serveur MCP
MCP suit une architecture client / serveur classique avec trois entités : un host (Claude Desktop, IDE, agent), un client intégré au host, et un ou plusieurs serveurs que vous écrivez. La communication passe par JSON-RPC 2.0 sur un transport — généralement stdio en local, ou HTTP/SSE en remote.
Flux d'un appel typique
# Schéma textuel d'un échange MCP
[Claude Desktop] [Serveur MCP]
│ │
│ ─── initialize (handshake) ─────────► │
│ ◄── server capabilities ───────────── │
│ │
│ ─── tools/list ──────────────────────► │
│ ◄── { tools: [create_issue, ...] } ── │
│ │
│ User: "crée un ticket bug login" │
│ Claude analyse → décide tool call │
│ ─── tools/call create_issue ────────► │
│ │ → exécute logique
│ ◄── { content: [...], isError: false }│
│ │
│ Claude formule la réponse finale │
jsonrpc:"2.0", id, method, params. Pas de types compliqués, pas de WebSocket obligatoire. Le SDK gère tout, vous écrivez juste vos fonctions métier.
SDK TypeScript : premier serveur
Le SDK officiel @modelcontextprotocol/sdk couvre client et serveur. On part d'un projet vide et on construit un serveur minimal qui expose un seul tool — un calculateur de TVA pour démarrer.
npx @modelcontextprotocol/inspector). Un projet npm vide suffit — pas besoin de framework.
Installation et structure projet
# Création du projet
mkdir mcp-tva && cd mcp-tva
npm init -y
# Dépendances
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
# Config TypeScript stricte
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext
# Permettre l'exécution directe
echo '#!/usr/bin/env node' > src/server.ts
Squelette du serveur
// src/server.ts — serveur MCP minimal
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
// 1. Création de l'instance serveur avec metadata
const server = new Server(
{ name: 'mcp-tva', version: '1.0.0' },
{ capabilities: { tools: {} } }, // on déclare ce qu'on supporte
);
// 2. Schéma Zod pour valider les arguments du tool
const TvaArgs = z.object({
amount: z.number().positive().describe('Montant HT en euros'),
rate: z.number().min(0).max(100).default(20).describe('Taux TVA en %'),
});
// 3. Handler : Claude demande la liste des tools disponibles
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'compute_tva',
description: 'Calcule la TVA et le montant TTC à partir d\'un HT.',
inputSchema: {
type: 'object',
properties: {
amount: { type: 'number', description: 'Montant HT en euros' },
rate: { type: 'number', description: 'Taux TVA % (défaut 20)' },
},
required: ['amount'],
},
},
],
}));
// 4. Handler : Claude appelle le tool
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== 'compute_tva') {
throw new Error(`Tool inconnu : ${request.params.name}`);
}
// Validation stricte (sinon Claude peut envoyer n'importe quoi)
const { amount, rate } = TvaArgs.parse(request.params.arguments);
const tva = +(amount * rate / 100).toFixed(2);
const ttc = +(amount + tva).toFixed(2);
// Format de retour MCP : array de "content blocks"
return {
content: [{
type: 'text',
text: `HT: ${amount}€ | TVA (${rate}%): ${tva}€ | TTC: ${ttc}€`,
}],
};
});
// 5. Démarrage : on parle stdio (process.stdin / process.stdout)
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[mcp-tva] serveur démarré'); // stderr — pas stdout (réservé JSON-RPC)
stdout autre chose que des messages JSON-RPC. Tout console.log casse le protocole. Utilisez console.error pour vos logs (qui va sur stderr).
Tools : exposer des fonctions
Les tools sont la primitive la plus puissante : Claude décide tout seul de les appeler en fonction du contexte. Quelques règles font la différence entre un tool ignoré et un tool utilisé naturellement par Claude.
Tool métier : recherche dans une base de tickets
// src/tools/search-tickets.ts — exemple production
import { z } from 'zod';
import { db } from '../db.js';
// Schéma d'entrée — chaque description est une instruction pour Claude
export const searchTicketsSchema = {
name: 'search_tickets',
description: [
'Recherche des tickets de support par mots-clés et statut.',
'Utilise ce tool quand l\'utilisateur demande des tickets,',
'des bugs en cours, ou veut filtrer par projet.',
].join(' '),
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Mots-clés de recherche full-text' },
status: {
type: 'string',
enum: ['open', 'in_progress', 'closed'],
description: 'Filtre statut (par défaut tous statuts ouverts)',
},
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
},
required: ['query'],
},
};
const ArgsSchema = z.object({
query: z.string().min(1),
status: z.enum(['open', 'in_progress', 'closed']).optional(),
limit: z.number().int().min(1).max(50).default(10),
});
export async function searchTickets(rawArgs: unknown) {
// Validation stricte avant tout accès DB
const { query, status, limit } = ArgsSchema.parse(rawArgs);
// Requête paramétrée (jamais de concat de strings)
const rows = await db.query(
`SELECT id, title, status, created_at
FROM tickets
WHERE search_vector @@ plainto_tsquery('french', $1)
${status ? 'AND status = $2' : ''}
ORDER BY created_at DESC
LIMIT ${status ? '$3' : '$2'}`,
status ? [query, status, limit] : [query, limit],
);
// Réponse MCP : on renvoie du texte structuré que Claude peut citer
return {
content: [{
type: 'text',
text: rows.length === 0
? `Aucun ticket trouvé pour "${query}".`
: rows.map(r => `#${r.id} [${r.status}] ${r.title}`).join('\n'),
}],
};
}
Bonnes pratiques pour les descriptions
- Verbes d'action explicites : "Recherche", "Crée", "Calcule" — pas "Permet de…"
- Cas d'usage en exemple : "Utilise quand l'utilisateur demande X" — guide Claude
- Limites mentionnées : "Retourne max 10 résultats", "lecture seule"
- Description sur chaque champ : Claude lit aussi les descriptions des paramètres
Resources : exposer des données
Là où les tools sont activés par le modèle, les resources sont sélectionnées par l'utilisateur ou l'application. Elles sont identifiées par une URI (custom scheme libre) et peuvent être listées ou lues. Cas typiques : fichiers d'un projet, documents Notion, fiches CRM.
Exposer des fiches produit comme resources
// src/resources.ts — fiches produit en lecture seule
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { db } from './db.js';
// Liste : chaque resource a une URI unique
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const products = await db.query('SELECT id, name FROM products WHERE published');
return {
resources: products.map(p => ({
uri: `product://${p.id}`, // URI custom — libre
name: p.name, // affiché dans Claude Desktop
description: `Fiche complète de ${p.name}`,
mimeType: 'application/json', // Claude saura comment l'interpréter
})),
};
});
// Lecture : Claude Desktop demande le contenu d'une URI sélectionnée
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
const match = uri.match(/^product:\/\/(\d+)$/);
if (!match) throw new Error(`URI non gérée : ${uri}`);
const id = parseInt(match[1], 10);
const product = await db.queryOne('SELECT * FROM products WHERE id=$1', [id]);
if (!product) throw new Error(`Produit ${id} introuvable`);
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(product, null, 2),
}],
};
});
get_product(id) est appelé automatiquement par Claude. Une resource product://42 est ajoutée manuellement par l'utilisateur dans le contexte de la conversation (drag & drop dans Claude Desktop). Choisissez selon qui doit décider.
Prompts : templates réutilisables
Les prompts MCP sont des slash commands que l'utilisateur invoque depuis le client. Ils prennent éventuellement des arguments et retournent une séquence de messages à injecter dans la conversation. Idéal pour standardiser des workflows : code review, génération de tests, audit sécurité.
Prompt "code review" avec arguments
// src/prompts.ts — slash command /code-review
import {
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { readFile } from 'node:fs/promises';
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
prompts: [
{
name: 'code-review',
description: 'Lance une revue de code complète sur un fichier donné.',
arguments: [
{ name: 'path', description: 'Chemin du fichier à reviewer', required: true },
{ name: 'focus', description: 'Aspect prioritaire (perf, secu, lisibilite)', required: false },
],
},
],
}));
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
if (request.params.name !== 'code-review') throw new Error('Inconnu');
const path = request.params.arguments?.path;
const focus = request.params.arguments?.focus ?? 'lisibilite';
const code = await readFile(path, 'utf-8');
// On retourne une séquence de messages prêts à être envoyés à Claude
return {
description: `Code review de ${path}`,
messages: [
{
role: 'user',
content: {
type: 'text',
text: [
`Tu es un reviewer senior. Focus principal : ${focus}.`,
`Voici le fichier ${path} :`,
'```',
code,
'```',
'',
'Donne :',
'1. Un résumé en 3 points',
'2. Les bugs potentiels (avec ligne)',
'3. Les améliorations rangées par impact',
].join('\n'),
},
},
],
};
});
/code-review path=./src/auth.ts et Claude reçoit le message construit. Aucun copier-coller, aucun template à mémoriser.
Connecter à Claude Desktop
Une fois votre serveur prêt, on l'enregistre dans la config Claude Desktop. Le client lance automatiquement votre process en stdio à chaque démarrage.
Fichier de configuration
// macOS : ~/Library/Application Support/Claude/claude_desktop_config.json
// Windows : %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"tva-calculator": {
"command": "node",
"args": ["/Users/votre-user/projects/mcp-tva/dist/server.js"],
"env": {
"DB_URL": "postgres://localhost/myapp",
"LOG_LEVEL": "info"
}
},
"support-tickets": {
"command": "npx",
"args": ["-y", "tsx", "/path/to/mcp-tickets/src/server.ts"]
}
}
}
Vérification rapide
# 1. Build
npm run build
# 2. Test du serveur en dehors de Claude (debug pratique)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/server.js
# 3. Inspecter avec l'outil officiel
npx @modelcontextprotocol/inspector node dist/server.js
# → ouvre une UI web pour tester chaque tool / resource / prompt
# 4. Restart Claude Desktop pour recharger la config
@modelcontextprotocol/inspector. UI web qui liste tools/resources/prompts et permet de les déclencher manuellement avec arguments arbitraires. Indispensable pour itérer.
Transport HTTP / SSE pour le cloud
Le transport stdio est parfait en local mais ne passe pas à l'échelle pour un service multi-utilisateur. MCP supporte aussi un transport HTTP avec SSE pour les events serveur, ce qui permet d'héberger un serveur MCP partagé (Cloudflare Workers, Lambda, conteneur).
Serveur HTTP avec SSE
// src/server-http.ts — serveur MCP exposé en HTTP/SSE
import express from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
const app = express();
app.use(express.json());
const mcpServer = new Server(
{ name: 'mcp-cloud', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {}, prompts: {} } },
);
// ... handlers tools/resources/prompts (cf. sections précédentes)
// Map des transports actifs : un par session/connexion SSE
// Indispensable pour router les POST entrants vers la bonne session
const transports = new Map<string, SSEServerTransport>();
// 1. Endpoint SSE : Claude Desktop / agents s'y connectent et restent ouverts
app.get('/mcp/sse', async (req, res) => {
// Auth Bearer — refus immédiat si token absent ou invalide
const auth = req.headers.authorization?.replace('Bearer ', '');
if (!auth || !verifyToken(auth)) {
return res.status(401).end();
}
// Le SDK génère un sessionId unique exposé via transport.sessionId
const transport = new SSEServerTransport('/mcp/messages', res);
transports.set(transport.sessionId, transport);
// Nettoyage à la déconnexion (sinon fuite mémoire)
res.on('close', () => transports.delete(transport.sessionId));
await mcpServer.connect(transport);
});
// 2. Endpoint POST : messages JSON-RPC entrants, routés par sessionId
app.post('/mcp/messages', async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = transports.get(sessionId);
if (!transport) {
return res.status(404).json({ error: 'Session inconnue ou expirée' });
}
await transport.handlePostMessage(req, res);
});
app.listen(4000, () => console.error('MCP HTTP on :4000'));
Comparatif des transports
| Transport | Cas d'usage | Auth | Multi-utilisateur |
|---|---|---|---|
stdio |
Outils locaux (FS, git, DB locale) | Aucune (process local) | Non — un process par client |
SSE |
Service cloud, équipe | Bearer token / OAuth | Oui — sessions HTTP |
WebSocket (expérimental) |
Latence faible, full-duplex | Sub-protocol | Oui |
Sécurité, scopes et bonnes pratiques
Un serveur MCP est un point d'entrée vers vos systèmes. Le LLM n'est pas votre périmètre de sécurité — votre serveur l'est.
Pattern de validation Zod systématique
// src/middleware/validate.ts — wrap chaque handler
import { ZodSchema } from 'zod';
export function validated<T>(
schema: ZodSchema<T>,
handler: (args: T) => Promise<unknown>,
) {
return async (rawArgs: unknown) => {
const result = schema.safeParse(rawArgs);
if (!result.success) {
// Format MCP d'erreur : on retourne content + isError
return {
content: [{
type: 'text',
text: `Arguments invalides : ${result.error.message}`,
}],
isError: true,
};
}
return handler(result.data);
};
}
// Usage
const handleSqlQuery = validated(
z.object({ sql: z.string().regex(/^\s*SELECT/i, 'Lecture seule uniquement') }),
async ({ sql }) => {
const rows = await db.query(sql);
return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
},
);
- Schéma Zod (ou JSON Schema strict) sur chaque tool/prompt
- Lecture seule par défaut — opérations destructives marquées explicitement
- Requêtes DB toujours paramétrées (jamais de concat string)
- Auth Bearer / OAuth sur transport HTTP
- Rate limiting par client (10-100 req/min selon contexte)
- Timeout strict sur chaque tool (30s max)
- Logs JSON structurés : tool, args, durée, success
- Audit trail : qui a appelé quoi, quand
- Tests :
@modelcontextprotocol/inspectoren CI sur les tools critiques - Versioning sémantique du serveur (capabilities négociées)
description commence par "⚠ Destructif :" — l'utilisateur lit cette ligne avant d'autoriser.
Conclusion
MCP transforme l'intégration LLM ↔ outils d'un patchwork artisanal en un protocole standard, partagé et versionné. Avec quelques heures de TypeScript, vous donnez à Claude (Desktop ou Code) une porte d'entrée propre vers votre base de données, votre CRM, votre backend métier — sans réécrire la mécanique d'intégration à chaque nouveau client.
Trois leviers pour un serveur de qualité : tools auto-descriptifs (le modèle ne lit que vos descriptions), validation Zod systématique (le LLM est créatif avec les arguments), et transports adaptés au contexte (stdio en local, SSE pour le cloud). Le reste est de l'architecture standard : sécurité, observabilité, tests.
Pour aller plus loin : explorez les serveurs officiels (@modelcontextprotocol/server-filesystem, -postgres, -github) pour voir des patterns avancés, et publiez le vôtre sur npm pour que toute votre équipe en bénéficie en une commande.
- Tool use natif : si vous n'avez besoin que de fonctions ad-hoc dans un seul appel API, le tool use de l'API Claude reste plus léger que MCP
- Embeddings & RAG : pour donner du contexte documentaire à un tool MCP, couplez-le à pgvector + OpenAI (cf. nos articles IA)
- Agents autonomes : un serveur MCP partagé permet à plusieurs agents (Claude Code, Cursor, n8n) de réutiliser les mêmes tools sans duplication
- Observabilité : exposez les métriques (durée, erreurs par tool) via Prometheus pour piloter en production