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.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.