Retour au blog
IA

J'ai branché Claude Desktop sur mon appli de réservation de badminton (et le protocole n'a rien prévu pour m'empêcher de tout casser)

La dernière fois, je vous ai raconté comment j'avais construit un chat qui réserve des créneaux de badminton sans qu'un LLM puisse décider tout seul de vider le planning au nom de n'importe qui. Confirmation obligatoire pour tout ce qui a un effet de bord, isolation stricte du userId, whitelist des outils, sanitization du contexte. Cinq couches de défense en profondeur, expliquées avec du code.

Cette fois, je voulais voir ce qui se passe quand on retire le chat et qu'on branche directement Claude Desktop (ou n'importe quel client MCP) sur la même appli. Spoiler : le protocole MCP n'a strictement rien prévu pour gérer une confirmation utilisateur ou une session HTTP. Il a fallu retransposer les deux invariants qui protégeaient le chat interne, sans rien en perdre, sur un protocole qui n'a aucune de ces notions nativement.

Pourquoi MCP change la donne

Le chat interne, c'est mon code du début à la fin. Le message arrive, je construis le contexte, j'appelle le LLM, je décide quoi faire du tool call, je streame la réponse. Je contrôle chaque frontière.

MCP, c'est l'inverse. N'importe quel client MCP (Claude Desktop, un autre agent, un outil que je n'ai jamais vu) peut se connecter à mon serveur et appeler mes outils. Le protocole définit comment déclarer un outil, comment le client l'appelle, et comment la réponse revient. Il ne définit rien sur « est-ce que cet utilisateur a le droit de faire ça », ni sur « cette action nécessite-t-elle un accord humain avant de s'exécuter ». Ces deux questions, c'était déjà tout le sujet du premier article. Là, il fallait les résoudre une deuxième fois, dans un contexte où je ne contrôle plus le client.

Le principe reste inchangé : le modèle propose, il n'exécute jamais tout seul une action à effet de bord. Mais le mécanisme qui l'impose doit être entièrement repensé, parce que MCP n'a ni notion de session HTTP authentifiée par cookie, ni notion de « clic sur un bouton Confirmer ». Le concept même n'existe pas dans la spec.

Un token statique, parce que ce projet n'a pas de vrai login

Première question : d'où vient le userId quand un client MCP appelle un outil ? Dans le chat interne, il vient de la session HTTP. En MCP, il n'y a pas de notion de session au sens classique. Chaque requête HTTP vers /mcp doit porter de quoi s'authentifier elle-même.

Ce projet n'a jamais eu de vrai système de login (ce n'est pas le sujet), donc pas question d'aller construire OAuth pour l'occasion. J'ai pris la solution la plus simple qui respecte l'invariant : un token statique par utilisateur, généré au moment du seed et affiché en clair dans les logs pour qu'on puisse le copier-coller dans la config d'un client MCP pendant une démo.

src/seed/seed.service.tsTypeScript

const usersWithTokens = USER_NAMES.map((u) => ({
  ...u,
  mcpToken: randomBytes(24).toString('hex'),
}));
const users = await this.userModel.insertMany(usersWithTokens);

this.logger.log('MCP tokens (for local MCP client configuration):');
for (const user of users) {
  this.logger.log(`  ${user.name}: ${user.mcpToken}`);
}

Un guard NestJS résout ce token vers un userId avant que le moindre outil ne s'exécute :

src/mcp/mcp-auth.guard.tsTypeScript

async canActivate(context: ExecutionContext): Promise<boolean> {
  const request = context.switchToHttp().getRequest();
  const header: string | undefined = request.headers?.authorization;

  if (!header || !header.startsWith('Bearer ')) {
    throw new UnauthorizedException('Missing or malformed Authorization header');
  }

  const token = header.slice('Bearer '.length).trim();
  const user = await this.authService.findByMcpToken(token);

  if (!user) {
    throw new UnauthorizedException('Invalid MCP token');
  }

  request.mcpUserId = user._id.toString();
  return true;
}

Ce request.mcpUserId est la seule et unique source du userId utilisé pour exécuter une action, du début à la fin de la chaîne. Il ne vient jamais des arguments d'un tool call, exactement le même invariant que dans le chat interne (couche 3 de l'article précédent), juste transposé sur un protocole différent. Le guard tourne avant que le nom de l'outil ne soit même examiné. Un token absent ou invalide, et la requête est rejetée sans que la moindre logique métier n'ait tourné.

Deux entrées de confirmation au lieu d'une

Dans le chat interne, la confirmation, c'est un deuxième appel HTTP explicite (POST /chat/confirmations/:id) déclenché par un clic sur un bouton dans mon frontend React, que je contrôle entièrement. En MCP, il n'y a pas de frontend à moi dans la boucle, juste le client MCP (Claude Desktop, typiquement) qui décide quand rappeler un outil.

La traduction directe de « propose puis confirme » en MCP, c'est deux outils distincts au lieu d'un flag approve. proposeBooking crée l'enregistrement en attente et s'arrête là :

src/mcp/mcp-tools.service.tsTypeScript

async proposeBooking(userId: string, args: { slotId: string }) {
  const pending = await this.pendingToolCallModel.create({
    userId: new Types.ObjectId(userId),
    toolName: 'createBooking',
    arguments: { slotId: args.slotId },
    status: 'pending',
  });
  return {
    pendingId: pending._id.toString(),
    message: `Réservation en attente de confirmation. Appelle confirmBooking avec pendingId=${pending._id.toString()} pour valider.`,
  };
}

Rien n'est réservé à ce stade. C'est exactement le même modèle PendingToolCall, avec le même statut pending, que celui utilisé par le chat interne. Une réservation proposée via MCP et une proposée via le chat sont indiscernables en base : c'est la même notion métier vue par deux façades protocolaires différentes.

confirmBooking est un appel distinct, qui doit être déclenché explicitement par le client MCP (dans le cas de Claude Desktop, ça veut dire que le modèle décide de rappeler l'outil, généralement après avoir demandé son accord à l'utilisateur dans la conversation, mais rien dans le protocole ne force ce comportement, donc l'app ne peut pas s'y fier). Ce qui compte, c'est que rien n'est écrit en base tant que cet appel n'a pas eu lieu :

src/mcp/mcp-tools.service.tsTypeScript

async confirmBooking(userId: string, args: { pendingId: string }) {
  const pending = await this.loadOwnedPendingCall(userId, args.pendingId, 'createBooking');
  const bookingArgs = pending.arguments as { slotId: string };

  const booking = await this.bookingsService.createBooking({ slotId: bookingArgs.slotId, userId });
  await this.memoryGraphService.recordBooking({
    userId,
    bookingId: booking._id.toString(),
    slotStartsAt: new Date(),
  });

  pending.status = 'confirmed';
  await pending.save();

  return booking;
}

proposePreference / confirmPreference suivent exactement le même schéma. Le principe « le modèle propose, il n'exécute jamais tout seul » survit intact au changement de protocole. Il change juste de forme, d'un flag approve à deux noms d'outils distincts.

Le piège que le chat interne n'avait pas : confondre deux outils en attente

Ce problème-là, je ne l'avais pas dans le chat interne. Là-bas, il n'y a qu'un seul point de confirmation dynamique (streamConfirmToolCall), qui regarde le toolName stocké dans le PendingToolCall et dispatch en conséquence. Impossible de se tromper d'outil, il n'y a qu'une seule porte d'entrée.

En MCP, confirmBooking et confirmPreference sont deux outils séparés. Rien n'empêche, en théorie, qu'un client (buggé, ou malveillant) appelle confirmBooking avec le pendingId d'une préférence proposée via proposePreference. Le pendingId existe, appartient bien à l'utilisateur qui confirme, a bien le statut pending, mais ce n'est pas une réservation. Sans vérification supplémentaire, confirmBooking irait chercher un champ slotId qui n'existe pas dans les arguments stockés, et planterait de façon confuse, ou pire, ferait quelque chose d'inattendu selon comment le code gère l'absence du champ.

La correction est une seule vérification de plus, ajoutée au passage de propriétaire déjà nécessaire :

src/mcp/mcp-tools.service.tsTypeScript

private async loadOwnedPendingCall(
  userId: string,
  pendingId: string,
  expectedToolName: string,
): Promise<PendingToolCallDocument> {
  const pending = await this.pendingToolCallModel.findById(pendingId).exec();

  if (
    !pending ||
    pending.userId.toString() !== userId ||
    pending.toolName !== expectedToolName ||
    pending.status !== 'pending'
  ) {
    throw new NotFoundException(PENDING_NOT_FOUND_MESSAGE);
  }

  return pending;
}

Quatre conditions, un seul throw. Je tiens à ce point : pendingId inexistant, pendingId d'un autre utilisateur, pendingId déjà confirmé, et pendingId du mauvais outil renvoient exactement le même message d'erreur générique, « Pending action not found ». Aucun des quatre cas n'est distinguable de l'extérieur. Si je renvoyais une erreur différente pour « appartient à quelqu'un d'autre » que pour « n'existe pas », un client pourrait sonder des pendingId au hasard et apprendre lesquels existent vraiment, juste en observant quelle erreur revient.

Je ne l'ai pas vu du premier coup. J'avais bien mis la vérification toolName !== expectedToolName dans le code, mais aucun de mes tests ne la déclenchait vraiment, le garde-fou existait sans que rien ne prouve qu'il marchait. En me relisant, j'ai réalisé que j'avais testé « mauvais propriétaire » et « déjà confirmé », jamais « mauvais outil ». Deux tests de plus (confirmer une préférence via confirmBooking, confirmer une réservation via confirmPreference, vérifier le rejet dans les deux cas), et le point était clos.

Ce qui reste: avoir écrit le bon code du premier coup n'aurait rien valu sans un test qui le prouve. C'est la différence entre « je pense que ça marche » et « je peux le démontrer ».

Le validateur qui existait, mais que personne n'appelait

Celui-là, je ne l'ai vu qu'en faisant une relecture complète de la branche à la fin, pas au fil de l'eau. Il mérite d'être raconté parce que c'est exactement le genre de bug qu'une relecture rapide ne voit jamais.

Le SDK MCP valide déjà la forme des arguments d'un outil via des schémas zod, déclarés au moment d'enregistrer l'outil :

src/mcp/mcp-tools.service.tsTypeScript

server.registerTool(
  'proposeBooking',
  {
    description: 'Propose booking a slot. Does not book anything until confirmBooking is called.',
    inputSchema: { slotId: z.string() },
  },
  async (args) => { /* ... */ },
);

z.string() vérifie que slotId est bien une chaîne. Il ne vérifie pas que c'est un ObjectId Mongo valide. { slotId: "not-an-id" } passe très bien ce schéma-là, et va planter plus loin, dans les entrailles de BookingsService, avec une erreur Mongo brute. Moins propre, moins utile pour qui essaie de déboguer côté client MCP.

J'avais pourtant écrit, dans une tâche antérieure, un validateur dédié qui fait exactement cette vérification-là (même liste blanche de noms d'outils, mêmes contrôles de format d'ObjectId que celui qui protège déjà le chat interne), avec sa propre suite de tests qui passait au vert :

src/mcp/mcp-tool-call-validator.service.tsTypeScript

validate(name: string, rawArgs: unknown): ValidatedMcpToolCall {
  if (!ALLOWED_TOOL_NAMES.includes(name as (typeof ALLOWED_TOOL_NAMES)[number])) {
    throw new McpToolCallValidationError(`Tool "${name}" is not whitelisted`);
  }
  // ... contrôles par outil, ObjectId, string non vide, etc.
}

Le problème : ce service n'était enregistré nulle part comme provider, et rien ne l'appelait. Il existait, il était testé, il était vert, et il ne tournait jamais en pratique. La contrainte du plan qui disait « chaque tool call doit passer par ce validateur avant exécution » n'était techniquement pas respectée, alors même que le composant censé la satisfaire était déjà écrit et fonctionnel isolément.

Un test unitaire vert sur le validateur lui-même ne dit rien sur le fait qu'il tourne réellement en production. Ça ne saute pas aux yeux en relisant un fichier isolément, il a fallu que je me pose la question « ce fichier est-il seulement appelé quelque part ? », pas juste « ce fichier fait-il ce qu'il prétend faire ». La correction tient en quelques lignes : injecter le validateur dans le service, l'appeler avant chaque délégation vers la logique métier, et passer les arguments validés plutôt que les arguments bruts du SDK.

src/mcp/mcp-tools.service.tsTypeScript

async (args) => {
  const validated = this.mcpToolCallValidator.validate('proposeBooking', args);
  if (validated.name !== 'proposeBooking') throw new Error('Unreachable');
  const result = await this.proposeBooking(userId, validated.arguments);
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
}

Et pour prouver que le trou est bouché, un test qui appelle vraiment l'endpoint HTTP /mcp, avec un slotId invalide, et vérifie que l'appel est rejeté avant même de toucher Mongo :

test/mcp.e2e-spec.tsTypeScript

const proposeResponse = await mcpCall('tools/call', {
  name: 'proposeBooking',
  arguments: { slotId: 'not-an-id' },
});

const body = JSON.parse(proposeResponse.body);
expect(body.result.isError).toBe(true);
expect(JSON.stringify(body.result.content)).toContain('slotId must be a valid ObjectId');
expect(await bookingModel.countDocuments({})).toBe(countBefore);

Un composant testé isolément et un composant réellement câblé dans le chemin d'exécution, ce sont deux affirmations différentes. La première ne prouve jamais la seconde.

Le SDK ne fait pas exactement ce que la doc laisse penser

Deux derniers pièges, plus terre à terre, découverts en essayant de faire tourner tout ça pour de vrai.

Le premier : StreamableHTTPServerTransport, la classe du SDK qui gère le protocole HTTP de MCP, répond par défaut en flux SSE, même pour un simple appel d'outil ponctuel qui n'a besoin de rien de plus qu'une réponse JSON classique. Sans le savoir, mon premier test qui faisait un JSON.parse() sur le corps de la réponse plantait avec une erreur de parsing complètement opaque (Unexpected token 'e', "event: mes"...). Il a fallu creuser dans les déclarations de types du SDK pour trouver l'option qui change ce comportement :

src/mcp/mcp.controller.tsTypeScript

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
  enableJsonResponse: true,
});

Une ligne, mais invisible tant qu'on n'a pas lu les types en détail. Le comportement par défaut du SDK n'est pas celui qu'on attendrait naturellement pour un simple appel-réponse.

Le deuxième piège est plus tordu, et propre à la façon dont ce backend teste ses endpoints HTTP. Ce projet utilise Fastify, pas Express, et les tests de bout en bout tapent directement sur l'application via app.inject, l'outil d'injection de requêtes de Fastify. Un socket simulé, pas une vraie connexion réseau. Le SDK MCP, lui, s'appuie en interne sur une bibliothèque (@hono/node-server) qui, dans certaines conditions de nettoyage de connexion, appelle socket.destroySoon(), une méthode Node standard sur un vrai socket, mais absente du socket simulé de Fastify. Résultat : le test plante avec TypeError: socket.destroySoon is not a function, alors que la même requête fonctionne parfaitement contre un vrai serveur lancé avec curl.

La correction reste strictement scopée au problème de test, sans toucher au comportement réel :

src/mcp/mcp.controller.tsTypeScript

const socket = request.raw.socket as unknown as { destroySoon?: () => void; destroy: () => void };
if (socket && typeof socket.destroySoon !== 'function') {
  socket.destroySoon = () => socket.destroy();
}

Ce genre de patch mérite d'être vérifié, pas juste écrit et oublié : est-ce que la condition risque un jour de remplacer une vraie implémentation ? Non, un vrai net.Socket a toujours destroySoon nativement, donc la condition ne se déclenche jamais en dehors du harnais de test. Je suis allé lire le code source de la dépendance en cause pour m'en assurer, pas juste me fier au commentaire que j'avais écrit à côté.

Ce qu'on retient

Le principe central du premier article, le LLM propose et n'exécute jamais directement une action à effet de bord, survit intact au changement de protocole. Ce qui change, c'est tout le mécanisme qui l'implémente.

Une session HTTP authentifiée devient un token statique résolu par un guard, mais le userId reste, comme avant, structurellement impossible à faire venir d'ailleurs que du serveur. Un flag approve sur un seul endpoint devient deux outils distincts (proposeX / confirmX), avec un nouveau risque à couvrir explicitement : confondre un pendingId d'un outil avec celui d'un autre. Un composant de sécurité testé isolément n'est pas un composant qui protège quoi que ce soit tant qu'il n'est pas réellement câblé dans le chemin d'exécution, et ça ne se voit qu'en traçant le graphe d'appels, jamais en relisant le fichier tout seul. Et la doc d'un SDK ne remplace jamais la lecture de ses types : le comportement par défaut, SSE plutôt que JSON, n'était nulle part évident avant de creuser.

Le code complet, module MCP compris, est disponible sur GitHub, si jamais vous voulez brancher Claude Desktop sur une appli qui réserve des terrains de badminton sans lui laisser les clés du camion.

Besoin d'aide sur ce sujet ?

Discutons de comment je peux vous accompagner.

Faire le diagnostic