Tutoriel · MCP · TypeScript · DevOps
Tester et publier un serveur MCP TypeScript
Des tests rapides sans processus ni réseau grâce au transport en mémoire, une journalisation qui ne casse pas le protocole, puis un paquet npm lançable par npx.
Trois niveaux de test
Un serveur MCP se teste comme une API, avec une particularité : son appelant final est un modèle. On distingue donc :
- les tests unitaires de la logique métier, indépendants de MCP ;
- les tests de protocole, qui appellent les outils par un vrai client MCP et vérifient schémas, sorties et erreurs ;
- les évaluations avec un modèle, qui mesurent s’il choisit le bon outil et le remplit correctement, selon la démarche décrite dans Évaluer un agent IA.
Cet article porte sur le deuxième niveau, le plus rentable : rapide, déterministe, et il attrape les régressions qui cassent les clients.
Tester sans processus ni réseau
Le SDK fournit un InMemoryTransport : deux extrémités reliées en mémoire, l’une pour un Client, l’autre pour le serveur. Les messages passent par tout le protocole (négociation, validation, sérialisation) sans lancer de processus. La fabrique creerServeur() du précédent article sert ici telle quelle.
// test/outils.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { creerServeur } from "../src/mcp.js";
let client: Client;
beforeEach(async () => {
// Deux extrémités reliées en mémoire : le vrai protocole, sans processus ni socket.
const [cote_client, cote_serveur] = InMemoryTransport.createLinkedPair();
await creerServeur().connect(cote_serveur);
client = new Client({ name: "tests", version: "0.0.0" });
await client.connect(cote_client);
});
describe("rechercher_tickets", () => {
it("renvoie une sortie structurée conforme", async () => {
const res = await client.callTool({ name: "rechercher_tickets", arguments: { recherche: "attestation" } });
expect(res.isError).toBeFalsy();
expect(res.structuredContent).toMatchObject({ total: expect.any(Number), tickets: expect.any(Array) });
});
it("refuse une recherche trop courte", async () => {
const res = await client.callTool({ name: "rechercher_tickets", arguments: { recherche: "a" } });
expect(res.isError).toBe(true);
});
});
describe("ajouter_commentaire", () => {
it("explique au modèle quoi faire si le ticket n'existe pas", async () => {
const res = await client.callTool({ name: "ajouter_commentaire", arguments: { ticketId: "T-9999", texte: "Test" } });
expect(res.isError).toBe(true);
expect(JSON.stringify(res.content)).toContain("rechercher_tickets");
});
}); Le service de ticketing est remplacé par un double en mémoire, injecté dans la fabrique : les tests ne dépendent d’aucun système extérieur.
Tester le contrat, pas seulement le code
Pour un client MCP, le nom des outils, leurs paramètres et leurs annotations forment un contrat. Renommer un paramètre casse silencieusement les prompts et les configurations qui s’y réfèrent. Un instantané de la liste des outils rend ce changement visible dans la revue de code :
it("expose les outils attendus avec leurs annotations", async () => {
const { tools } = await client.listTools();
const resume = tools.map((t) => ({
nom: t.name,
lectureSeule: t.annotations?.readOnlyHint ?? false,
parametres: Object.keys((t.inputSchema as any).properties ?? {}).sort(),
}));
// Un changement de nom, de paramètre ou d'annotation fait échouer le test : il doit être voulu.
expect(resume).toMatchSnapshot();
}); Le même principe vaut pour les descriptions : elles orientent le modèle, et une reformulation anodine peut changer son comportement. Les inclure dans l’instantané oblige à relancer les évaluations quand elles changent.
Journaliser sans casser le protocole
En stdio, la sortie standard est réservée aux messages JSON-RPC, comme vu dans le premier article. Les journaux vont sur la sortie d’erreur :
- une ligne JSON par événement sur
stderr(outil, durée, résultat), lisible par les agents et par un collecteur ; - aucun jeton, mot de passe ou donnée personnelle dans les arguments journalisés ;
- un niveau réglable par variable d’environnement, pour activer le détail lors d’un diagnostic.
Le protocole prévoit aussi des notifications de journal envoyées au client : utiles pour qu’un utilisateur suive une opération longue, elles ne remplacent pas un journal côté serveur.
Versionner un serveur
La version semver du paquet suit le contrat :
- correctif : un bug corrigé sans changement de schéma ;
- mineure : un outil ou un paramètre facultatif ajouté ;
- majeure : un outil renommé ou supprimé, un paramètre devenu obligatoire, un changement d’annotation qui rend un outil plus risqué.
La version déclarée dans new McpServer({ version }) est envoyée au client lors de la connexion : la lire depuis package.json évite qu’elles divergent.
Publier sur npm
Un serveur stdio publié sur npm se lance avec npx, sans installation préalable. Le package.json déclare l’exécutable, ne publie que le code compilé et bloque la publication si les tests échouent :
{
"name": "@acme-demo/mcp-support",
"version": "1.2.0",
"type": "module",
"bin": { "acme-mcp-support": "dist/server.js" },
"files": ["dist"],
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc",
"test": "vitest run",
"prepublishOnly": "npm run build && npm test"
}
} Le fichier dist/server.js commence par la ligne #!/usr/bin/env node, déjà présente dans le source. Les utilisateurs déclarent ensuite le serveur avec une version figée :
{
"mcpServers": {
"acme-support": {
"command": "npx",
"args": ["-y", "@acme-demo/mcp-support@1.2.0"],
"env": { "ACME_TICKETING_TOKEN": "${ACME_TICKETING_TOKEN}" }
}
}
} Figer la version protège d’une mise à jour qui changerait le comportement, et d’un paquet compromis publié sous le même nom. C’est le pendant, côté éditeur, de la règle « lire le code d’un serveur tiers avant de l’installer ».
Automatiser
Une intégration continue minimale compile et teste à chaque modification :
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
- run: npm test On peut y ajouter la publication sur étiquette de version, avec la provenance npm activée pour que les utilisateurs vérifient l’origine du paquet. La chaîne est la même que pour tout projet, voir CI/CD avec GitHub Actions. Ainsi s’achève la série : un serveur MCP conçu, exposé, testé et diffusé comme un logiciel à part entière.