Tutoriel serveur MCP en TypeScript : exemple SDK et modèle à copier
Un serveur MCP fonctionnel en TypeScript, de npm install à Claude Code. Copiez le modèle du SDK, enregistrez un outil, une ressource et un prompt, choisissez stdio ou Streamable HTTP, testez dans l’Inspector, puis ajoutez des outils d’image et de vidéo qui appellent une vraie API sans bloquer le client.
Vous pouvez relier un grand modèle de langage à votre propre code en une quarantaine de lignes. Ce tutoriel serveur MCP TypeScript construit exactement cela : un serveur fonctionnel sur le SDK officiel, un modèle de projet à copier et les deux transports qui comptent, stdio pour les clients locaux et Streamable HTTP pour les clients distants. Vous obtiendrez un outil, une ressource et un prompt, testés dans l’Inspector et enregistrés dans Claude Code. Nous ajoutons ensuite des outils d’image et de vidéo, car c’est là qu’un serveur Model Context Protocol cesse d’être une démo et se met à faire un vrai travail. Chaque extrait fonctionne avec Node.js 20 ou plus récent et le package @modelcontextprotocol/sdk.
Ce que fait un serveur MCP
Le Model Context Protocol (MCP) est un standard ouvert qui permet à un client d’IA, comme Claude Code, Claude Desktop ou un agent intégré à un IDE, d’appeler des fonctions et de lire des données qui vivent dans votre processus. Les messages circulent en JSON-RPC 2.0. Votre serveur annonce ce qu’il propose, le client liste ces capacités, et le modèle décide quand les utiliser. Votre code ne parle jamais directement au modèle. Il répond aux requêtes, et c’est pour cela que le serveur reste petit.
Trois briques de base
Chaque serveur MCP est composé d’un mélange de trois primitives :
Brique
Qui décide de l’utiliser
Usage typique
Outil
Le modèle
Interroger une base de données, appeler une API, générer une image
Ressource
L’application ou l’utilisateur
Exposer un document, un fichier ou une configuration comme contexte lisible
Prompt
L’utilisateur
Un modèle réutilisable, comme « examine cette pull request »
Dans la pratique, ce sont les outils qui font l’essentiel du travail. Un outil est une fonction nommée, avec un schéma d’entrée typé et un résultat texte ou image. Les ressources et les prompts sont facultatifs, mais ils coûtent presque rien à ajouter une fois le serveur en place.
Client, serveur et transport
Le transport n’est que le tuyau dans lequel circulent les messages JSON-RPC. Le même objet McpServer fonctionne en stdio comme en HTTP. La bonne structure consiste donc à construire le serveur dans une seule fonction et à attacher un transport dans un fichier d’entrée séparé. Le modèle ci-dessous suit cette règle, ce qui simplifie les tests et permet de livrer les deux transports à partir d’une seule base de code.
Préparer le projet TypeScript
Installer le SDK et zod
Créez un dossier et installez les dépendances. Le SDK utilise zod pour les schémas d’entrée : il les convertit en JSON Schema pour le client et valide chaque argument reçu avant l’exécution de votre handler.
L’arborescence du projet est volontairement simple :
mcp-notes-server/
src/
index.ts stdio transport and startup
http.ts Streamable HTTP transport
server.ts buildServer() factory
package.json
tsconfig.json
💡 Les imports du SDK se terminent par .js, même dans les fichiers TypeScript. Avec une résolution Node16, le compilateur exige des extensions explicites, et une extension manquante apparaît à l’exécution sous la forme ERR_MODULE_NOT_FOUND.
Construire le modèle de serveur
La fabrique du serveur
Placez tout ce que propose le serveur dans src/server.ts. Cette version enregistre un outil qui sauvegarde une note et un outil qui en recherche une :
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const notes = new Map<string, string>();
export function buildServer(): McpServer {
const server = new McpServer({ name: "notes-server", version: "1.0.0" });
server.registerTool(
"add_note",
{
title: "Add note",
description: "Save a short note under a unique id. Overwrites an existing id.",
inputSchema: {
id: z.string().min(1).describe("Unique id, for example 'standup-0612'"),
text: z.string().min(1).max(2000).describe("The note body"),
},
},
async ({ id, text }) => {
notes.set(id, text);
return { content: [{ type: "text", text: `Saved note ${id}` }] };
}
);
server.registerTool(
"get_note",
{
title: "Get note",
description: "Return the text of a saved note by id.",
inputSchema: { id: z.string().min(1).describe("The note id") },
},
async ({ id }) => {
const text = notes.get(id);
if (text === undefined) {
return { isError: true, content: [{ type: "text", text: `No note with id ${id}` }] };
}
return { content: [{ type: "text", text }] };
}
);
// resources and prompts go here (next section)
return server;
}
Deux détails comptent plus qu’il n’y paraît. D’abord, la description est ce que lit le modèle pour décider s’il appelle l’outil ; rédigez-la donc comme une documentation destinée à un collègue. Ensuite, en cas d’échec, renvoyez isError: true avec un message lisible plutôt que de lever une exception. Le modèle peut alors réessayer avec un argument corrigé ou expliquer le problème à l’utilisateur.
Ajouter une ressource et un prompt
Remplacez le commentaire provisoire par ceci :
server.registerResource(
"all-notes",
"notes://all",
{
title: "All notes",
description: "Every saved note as JSON",
mimeType: "application/json",
},
async (uri) => ({
contents: [{ uri: uri.href, text: JSON.stringify([...notes.entries()]) }],
})
);
server.registerPrompt(
"summarize-notes",
{
title: "Summarize notes",
description: "Ask for a short summary of the saved notes",
argsSchema: { tone: z.string().optional() },
},
({ tone }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Summarize my saved notes in a ${tone ?? "neutral"} tone.`,
},
},
],
})
);
Les ressources sont adressées par URI (notes://all), et les clients les affichent généralement dans un sélecteur pour que l’utilisateur puisse les joindre comme contexte. Les prompts apparaissent sous forme de commandes slash ou d’entrées de menu, selon le client.
Laisser un modèle de code vous aider
Une fois ce modèle de projet opérationnel, un modèle de code peut ajouter des outils en quelques minutes. Collez src/server.ts dans une conversation et demandez un nouvel outil qui suit le même schéma : d’abord le schéma, puis isError en cas d’échec. Claude Sonnet 5, Kimi K2.6 et GPT 5.6 Sol sont tous conçus pour le travail sur le code et disponibles sur PicassoIA. Relisez le schéma généré avant de l’accepter : un modèle rendra volontiers facultatif un champ qui devrait être obligatoire.
Choisir un transport
stdio pour les clients locaux
stdio est le transport le plus simple. Le client lance votre serveur comme processus enfant et communique avec lui par stdin et stdout. Créez src/index.ts :
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildServer } from "./server.js";
const server = buildServer();
await server.connect(new StdioServerTransport());
console.error("notes-server ready on stdio");
💡 N’utilisez jamais console.log dans un serveur stdio. La sortie standard transporte le protocole : une seule ligne de log parasite corrompt le flux, et le client se déconnecte avec une erreur d’analyse. Envoyez les logs sur stderr avec console.error.
Streamable HTTP pour les clients distants
Pour un serveur hébergé sur une machine distante plutôt que sur un ordinateur portable, utilisez Streamable HTTP. Il a remplacé l’ancien transport HTTP plus SSE et ne nécessite qu’un seul point de terminaison. Installez Express avec npm install express et npm install -D @types/express, puis enregistrez ceci dans src/http.ts :
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./server.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => console.error("MCP endpoint on http://localhost:3000/mcp"));
Avec sessionIdGenerator: undefined, le transport fonctionne en mode stateless : un serveur neuf par requête, aucune session à suivre, et une montée en charge horizontale comme pour n’importe quelle API. Si vous avez besoin de notifications émises par le serveur ou de flux reprenables, passez en mode stateful avec des identifiants de session.
stdio
Streamable HTTP
Lieu d’exécution
Processus enfant du client
Tout hôte accessible en HTTP
Authentification
Hérite de l’environnement de l’utilisateur
À ajouter (OAuth ou jetons bearer)
Idéal pour
Outils personnels et locaux pour développeurs
Serveurs partagés et hébergés
Mise à l’échelle
Un processus par client
Sans état, mise à l’échelle comme une API
Tester avant de connecter
Lancer le MCP Inspector
L’Inspector est l’interface de débogage officielle. Compilez le projet et lancez votre serveur à travers elle :
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
Ouvrez l’URL locale affichée, cliquez sur Connect, puis utilisez l’onglet Tools pour lister les outils et exécuter add_note avec un argument JSON. Le volet d’historique affiche le trafic JSON-RPC brut, le moyen le plus rapide de repérer un schéma qui ne correspond pas à ce que vous aviez voulu.
Enregistrer dans Claude Code et Claude Desktop
Claude Code enregistre un serveur local avec une seule commande, et un serveur HTTP avec un indicateur de transport :
claude mcp add notes -- node /absolute/path/mcp-notes-server/dist/index.js
claude mcp add --transport http notes-remote http://localhost:3000/mcp
Claude Desktop lit à la place un fichier JSON (claude_desktop_config.json) :
💡 Utilisez des chemins absolus dans les deux cas. Un chemin relatif se résout par rapport au répertoire de travail du client, et non au vôtre, et le message d’erreur ne le dit presque jamais.
Ajouter des outils d’image et de vidéo
Un serveur de notes illustre ce principe. Les outils médias montrent pourquoi il en vaut la peine : tâches longues, sorties volumineuses et API externe avec ses propres limites. PicassoIA expose une API pour développeurs de type Replicate à l’adresse https://api.picassoia.com/v1, authentifiée par un jeton Bearer qui commence par pia_sk_. Quatre modèles sont accessibles via l’API et le connecteur MCP : PicassoIA Image pour le texte vers image, PicassoIA Image Editor Pro pour les retouches, PicassoIA Video pour la vidéo, et Seedance 2.5 Lite pour la vidéo avec audio. Les tâches sont asynchrones : vous créez une prédiction, vous l’interrogez, puis vous lisez le résultat.
Encapsuler un point de terminaison image
Deux petites fonctions d’aide servent tous les modèles, car la forme de l’API est la même :
const API = "https://api.picassoia.com/v1";
const headers = {
Authorization: `Bearer ${process.env.PICASSOIA_API_TOKEN}`,
"Content-Type": "application/json",
};
export async function createPrediction(model: string, input: Record<string, unknown>) {
const res = await fetch(`${API}/models/${model}/predictions`, {
method: "POST",
headers,
body: JSON.stringify({ input }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`Create failed: HTTP ${res.status}`);
return (await res.json()) as { id: string };
}
export async function getPrediction(id: string) {
const res = await fetch(`${API}/predictions/${id}`, { headers });
if (!res.ok) throw new Error(`Status failed: HTTP ${res.status}`);
return (await res.json()) as { status: string; output?: unknown; error?: string };
}
L’outil image les utilise et attend jusqu’à deux minutes un résultat :
server.registerTool(
"generate_image",
{
title: "Generate image",
description: "Create an image from a text prompt and return its URL.",
inputSchema: { prompt: z.string().min(10).max(4000) },
},
async ({ prompt }) => {
try {
const { id } = await createPrediction("picassoia/picassoia-image", { prompt });
for (let i = 0; i < 60; i++) {
const job = await getPrediction(id);
if (job.status === "succeeded") {
return { content: [{ type: "text", text: JSON.stringify(job.output) }] };
}
if (job.status === "failed") throw new Error(job.error ?? "Generation failed");
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Timed out waiting for the image");
} catch (err) {
return { isError: true, content: [{ type: "text", text: String(err) }] };
}
}
);
La limite de 4 000 caractères dans le schéma correspond à la limite de prompt de l’API, et la page de chaque modèle liste ses champs d’entrée exacts et son format de sortie. L’API autorise aussi 5 prédictions simultanées par compte, partagées entre tous les jetons et connexions MCP ; mettez donc en file d’attente les appels d’outils parallèles au lieu de les lancer tous en même temps.
Gérer les tâches vidéo lentes
Une vidéo prend beaucoup plus de temps qu’une image, et un appel d’outil bloqué pendant plusieurs minutes peut dépasser le délai d’attente propre au client. Découpez le travail en deux outils : le premier lance la tâche et renvoie immédiatement son identifiant, le second vérifie son état.
server.registerTool(
"start_video",
{
title: "Start video",
description: "Start a video job from a prompt. Returns a prediction id to check later.",
inputSchema: { prompt: z.string().min(10).max(4000) },
},
async ({ prompt }) => {
const { id } = await createPrediction("picassoia/picassoia-video", { prompt });
return { content: [{ type: "text", text: JSON.stringify({ predictionId: id }) }] };
}
);
server.registerTool(
"check_video",
{
title: "Check video",
description: "Return the status and output of a video job by prediction id.",
inputSchema: { predictionId: z.string().min(1) },
},
async ({ predictionId }) => {
const job = await getPrediction(predictionId);
return { content: [{ type: "text", text: JSON.stringify(job) }] };
}
);
Le modèle appelle start_video, effectue d’autres tâches, puis interroge check_video jusqu’à ce que le statut indique succeeded. Rien ne bloque, et une tâche échouée n’est qu’un statut de plus à signaler. Pour une vidéo avec audio, passez au modèle picassoia/seedance-2.5-lite (Seedance 2.5 Lite) ; les fonctions d’aide ne changent pas.
Livrer en toute sécurité
Valider les entrées et protéger les secrets
Considérez chaque argument d’outil comme non fiable. Un modèle peut être influencé par le texte qu’il lit sur le web ou dans un fichier : une page piégée peut donc demander à votre outil de faire quelque chose que vous n’aviez jamais prévu. Trois réflexes limitent l’essentiel du risque :
Bornez chaque champ dans zod : min, max, enum et regex pour les identifiants.
Ne transmettez jamais d’arguments à une commande shell ou à une chaîne SQL. Utilisez des requêtes paramétrées et limitez les chemins de fichiers à un seul répertoire de base.
Lisez les jetons depuis des variables d’environnement, ne les codez jamais en dur et ne les renvoyez jamais dans un résultat d’outil.
Pour les clients stdio, définissez les secrets dans le bloc env de la configuration du client. Pour les serveurs HTTP, exigez un en-tête Authorization et vérifiez-le avant d’exécuter handleRequest.
Corriger les trois erreurs courantes
console.log en stdio. Remplacez-le par console.error.
Extensions .js manquantes. Un import comme ./server échoue à l’exécution sous Node16.
Descriptions vagues. Un outil nommé run avec la description « fait des choses » n’est jamais choisi, ou il est choisi avec de mauvais arguments. Nommez-le d’après l’action et précisez quand l’utiliser.
Pour publier, conservez la ligne shebang en haut de src/index.ts, exécutez npm run build, puis npm publish. N’importe qui peut l’enregistrer avec claude mcp add notes -- npx -y mcp-notes-server. Les serveurs HTTP se distribuent sous forme de conteneur ou s’exécutent sur n’importe quel hôte Node.
Essayez sur Picasso IA
Vous disposez désormais d’un modèle qui fonctionne en local, fonctionne à distance et peut appeler des modèles d’image et de vidéo. Le moyen le plus rapide de voir ce que renvoient ces outils est d’essayer d’abord les modèles à la main. Ouvrez PicassoIA Image et rédigez un prompt aussi précis que ceux que vous enverriez depuis generate_image : sujet, objectif, lumière, décor. Essayez ensuite PicassoIA Video ou Seedance 2.5 Lite pour animer l’idée, et notez quelle formulation donne le résultat voulu avant de coder en dur des valeurs par défaut dans votre serveur. Choisissez n’importe quel modèle du catalogue complet sur picassoia.com/en/all-models et branchez-le avec les mêmes deux fonctions d’aide. Créez dès aujourd’hui vos propres images sur Picasso IA, et laissez votre premier appel d’outil MCP vous remettre le résultat.