# Les appels d'outils MCP en double ont besoin d'une identité d'exécution

Une reconnexion est un problème de transport. Une seconde action est un problème d'exécution. Les équipes en subissent les conséquences lorsqu'elles confondent les deux et laissent un client réessayer chaque requête sans réponse visible.

MCP permet facilement de passer à côté de ce problème, car un appel d'outil peut franchir plusieurs frontières : un processus agent, un transport MCP, une passerelle d'action et une API HTTP ou une cible SSH. Une connexion peut disparaître après que la cible a accepté le travail, mais avant que l'agent ne reçoive le résultat. Si votre système renvoie alors l'appel, la cible voit deux requêtes valides. Elle n'a aucune raison de deviner que la seconde était accidentelle.

La solution n'est pas d'augmenter le budget de tentatives. Donnez à chaque action demandée une identité d'exécution, enregistrez son cycle de vie et prenez les décisions de nouvelle tentative à partir de cet enregistrement. Une empreinte de requête indique ce que l'appelant cherchait à faire. Un relevé d'activité indique si le système a déjà commencé ou terminé l'action. Vous avez besoin des deux.

## Une reconnexion n'autorise pas une nouvelle exécution

Lorsqu'un client se reconnecte après la rupture d'un flux, il sait que la communication a échoué. Il ne sait pas que l'appel d'outil initial a échoué.

La distinction paraît évidente jusqu'à ce qu'un middleware générique de nouvelle tentative soit ajouté sous un client MCP. Le middleware voit un délai d'attente, une réinitialisation de connexion ou l'absence de réponse. Il ne sait pas si le POST concernait une lecture, une écriture, une commande distante ou une opération irréversible. Il renvoie les octets, parce que c'est souvent ainsi que fonctionne le code de nouvelle tentative HTTP.

Pour une lecture comme `GET /repos/acme/api/branches`, cela peut être acceptable. Pour `POST /payments`, `DELETE /projects/atlas` ou une commande SSH qui modifie un hôte de production, cela peut créer un second effet de bord. La couche d'outils ne peut pas réparer la situation après coup en renvoyant simplement un seul résultat au modèle.

La spécification du transport MCP Streamable HTTP autorise explicitement les clients à reprendre la livraison des événements du serveur vers le client avec `Last-Event-ID` lorsqu'un flux est interrompu. C'est un mécanisme de récupération des messages d'un flux. Il ne transforme pas une seconde requête JSON-RPC `tools/call` en la même exécution. La documentation du SDK TypeScript sépare également les jetons de reprise du chemin de requête et autorise un middleware client autour de `fetch`. C'est précisément pourquoi les équipes doivent définir leur règle de nouvelle tentative dans le code, au lieu de supposer que le transport les protégera.

Utilisez cette règle :

> Reprenez un flux de réponse lorsque le protocole le permet. Renvoyez une action ayant un effet de bord uniquement lorsque la couche d'action peut l'identifier comme la même exécution.

Les cas difficiles ne sont pas les échecs nets. Ce sont ceux où le serveur commence le travail, où la réponse disparaît et où le client ne sait pas s'il doit attendre, reprendre le flux, consulter l'état ou réessayer. Votre conception doit rendre cette incertitude visible.

## Les identifiants JSON-RPC identifient des messages, pas des actions durables

L'identifiant d'une requête JSON-RPC sert à associer une requête à sa réponse. Il ne suffit pas à dédupliquer une action après une reconnexion, un redémarrage de processus ou une nouvelle exécution d'agent.

Considérez ces deux appels :

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

Il peut s'agir de la même requête envoyée deux fois après une perte de connexion. Ils peuvent aussi provenir de deux processus clients distincts qui commencent tous deux leur numérotation à 1 ou 41. Même au sein d'un seul processus, un bug d'implémentation peut réutiliser des identifiants. Cette valeur ne vous apprend presque rien si vous ne l'associez pas à un appelant authentifié et à une session de protocole précise.

Considérez maintenant deux appels avec des identifiants différents :

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

```json
{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

Il peut s'agir d'une nouvelle tentative de transport dont le client a généré un nouvel identifiant. L'agent peut aussi avoir demandé volontairement un second déploiement après avoir reçu un résultat incertain. Les identifiants de message sont des indices, pas la décision.

Ne commettez pas l'erreur inverse en dédupliquant pour toujours chaque appel d'outil correspondant. Déployer deux fois la même version peut être sans conséquence, voire intentionnel. Créer deux fois le même ticket externe peut être incorrect. Renouveler deux fois un identifiant peut bloquer un service. La classe d'action détermine la durée pendant laquelle une identité d'exécution reste pertinente.

Un modèle pratique sépare trois identifiants :

- **Identifiant de corrélation du protocole** : l'identifiant JSON-RPC et, le cas échéant, le contexte de session ou de flux MCP.
- **Identifiant d'exécution** : un identifiant créé par le serveur pour une tentative acceptée d'exécuter une action d'outil.
- **Empreinte d'intention** : un condensat stable de l'effet demandé, utilisé pour retrouver une exécution précédente lorsque la corrélation du protocole change.

Une fois ces éléments séparés, les journaux cessent de prétendre répondre à une question à laquelle ils ne peuvent pas répondre.

## Une empreinte utile décrit l'effet

Une empreinte de requête doit rester identique lorsque la livraison change et changer lorsque l'effet demandé change. Ne hachez pas les octets JSON bruts en appelant le résultat une empreinte. Le JSON brut varie selon l'ordre des propriétés, les espaces, les valeurs par défaut facultatives, les identifiants de requête et les changements de mise en forme sans conséquence.

Commencez par construire un enregistrement d'action canonique. Pour une action HTTP, il peut avoir cette forme :

```json
{
  "actor": "signed-process:com.example.agent",
  "tool": "deploy_release",
  "channel": "http",
  "target": "deploy-api.internal.example/releases",
  "credential_ref": "deploy-service",
  "method": "POST",
  "arguments": {
    "service": "catalog",
    "version": "2026.07.22",
    "region": "us-east-1"
  },
  "intent_scope": "run:5f8097"
}
```

Canonisez l'ordre des champs, supprimez ceux qui n'ont aucune signification sémantique et normalisez les équivalents connus avant de calculer un condensat. Si `region` vaut par défaut `us-east-1`, matérialisez-la toujours ou omettez-la toujours lorsque la cible fournit cette valeur. Mélanger les deux crée de faux écarts.

Le champ `actor` est important. Deux processus agents autorisés qui envoient des arguments identiques peuvent représenter des actions intentionnelles distinctes. `credential_ref` compte également. Une requête effectuée avec une identité de service n'est pas nécessairement équivalente à la même requête, sur le même chemin et avec le même corps, effectuée avec une autre identité. Pour SSH, incluez l'identité de l'hôte, le compte, la commande, le répertoire de travail s'il modifie le comportement et, lorsque cela est possible, une représentation normalisée de la commande.

Ne mettez pas de secrets dans l'enregistrement canonique. Ne placez jamais de jetons bearer, de clés privées ou d'en-têtes d'autorisation bruts dans les données d'entrée d'une empreinte. Si un argument contient lui-même un secret, remplacez-le par une référence interne protégée ou calculez l'empreinte avec une construction à clé comme HMAC. Un hachage simple et non salé d'un secret à faible entropie transforme votre espace d'audit en oracle permettant de tester des suppositions.

La recommandation familière « hachez simplement la requête » est populaire parce qu'elle est courte. Elle est incorrecte pour le contrôle des actions. Un hachage prouve seulement que certains octets ont été transmis à une fonction. Il n'indique pas si ces octets représentent le même acteur, le même effet sur la cible ou la même fenêtre de nouvelle tentative.

## Le relevé d'activité doit contenir des états, pas une seule ligne de journal

Un relevé d'activité utile indique à quel endroit l'action s'est arrêtée. S'il ne contient que les états réussite et échec, une reconnexion vous laissera dans le doute au moment où vous avez le plus besoin d'une réponse claire.

Enregistrez au moins ces transitions pour chaque identifiant d'exécution :

1. **Acceptée** : la passerelle a validé la requête et attribué un identifiant d'exécution.
2. **Autorisée** : l'approbation ou l'autorisation de session requise a permis l'action.
3. **Envoyée** : la passerelle a transmis l'action au client HTTP ou à l'assistant SSH.
4. **Résultat observé** : la réponse de la cible, le code de sortie ou un échec explicite de livraison est arrivé.
5. **Résultat livré** : l'agent a reçu le résultat de l'outil, si le transport permet de l'établir.

Les quatrième et cinquième états doivent rester séparés. Une cible peut renvoyer HTTP 201 alors que la connexion au client MCP se rompt avant qu'il ne voie la réponse. Marquer cette exécution comme échouée parce que la livraison du résultat a échoué serait un mensonge. La marquer comme terminée donne au code de récupération quelque chose d'utile : il peut renvoyer ou reconstituer le résultat connu sans envoyer une nouvelle requête.

Voici le format d'enregistrement que je veux voir pendant un incident :

```json
{
  "execution_id": "act_01J4K8J7DX7V",
  "fingerprint": "hmac-sha256:4a1e...d90c",
  "tool": "deploy_release",
  "actor": "signed-process:com.example.agent",
  "target": "deploy-api.internal.example/releases",
  "state": "completed_result_not_delivered",
  "accepted_at": "2026-07-22T14:03:18Z",
  "dispatched_at": "2026-07-22T14:03:19Z",
  "completed_at": "2026-07-22T14:03:25Z",
  "target_status": 201,
  "result_reference": "result_01J4K8JFM2"
}
```

L'enregistrement n'a pas besoin d'exposer la réponse complète de la cible à chaque opérateur. Il doit fournir suffisamment de détails protégés pour que la passerelle prenne une décision de récupération, ainsi que suffisamment d'informations lisibles pour qu'une personne comprenne ce qui s'est passé.

Le journal Activity de Sallyport enregistre les appels individuels, tandis que son journal Sessions enregistre les exécutions des agents. Cette séparation est utile pour l'analyse : l'exécution indique quel processus agent existait et l'enregistrement de l'appel indique si une action particulière vers l'extérieur a franchi la frontière d'envoi. Sa chaîne d'audit peut également être vérifiée hors ligne avec `sp audit verify`, ce qui aide à établir que l'enregistrement n'a pas été discrètement réécrit après un incident.

## Traitez les résultats inconnus séparément

La plupart des actions en double commencent avec un système qui ne connaît que deux résultats : réussite et échec. Les actions réseau en nécessitent un troisième : inconnu.

Inconnu ne signifie pas que le système n'a rien fait. Cela signifie qu'il ne peut pas prouver si la cible a accepté l'action. Un délai d'attente avant la sortie des premiers octets de votre processus peut souvent être retenté sans risque. Un délai d'attente après la remise du corps d'une requête HTTP au système d'exploitation est un événement différent. Une connexion SSH interrompue après le démarrage d'une commande sur le shell distant est plus grave encore, car la commande peut continuer après la fin du processus local.

Classez chaque action d'outil avant de décider de sa récupération :

| Type d'action | Exemple | Comportement par défaut après un résultat inconnu |
|---|---|---|
| Lecture seule | Récupérer l'état d'une compilation | Réessayer avec des limites ordinaires |
| Écriture idempotente | Mettre une ressource nommée dans un état déclaré | Réessayer avec la même identité d'idempotence |
| Écriture conditionnelle | Mettre à jour uniquement si la version correspond | Consulter l'état, puis réessayer seulement si la condition tient toujours |
| Action irréversible | Envoyer un paiement, révoquer un accès, renouveler un identifiant | Arrêter et demander une vérification explicite |
| Commande distante | Exécuter une migration via SSH | Consulter un marqueur durable ou demander une vérification |

Le verbe HTTP de l'API ne suffit pas à remplir ce tableau. `PUT` est souvent décrit comme idempotent, mais une extrémité mal conçue peut envoyer une notification, déclencher une compilation ou ajouter un événement d'audit à chaque réception. `POST` peut être répétable sans danger lorsque l'API respecte une clé d'idempotence. Examinez le contrat réel de la cible.

Pour les commandes distantes longues, ajoutez un marqueur durable avant de lancer le travail. Une commande de migration peut créer un enregistrement avec un identifiant d'exécution, le mettre à jour au début du travail et le marquer comme terminé uniquement après validation. À la reconnexion, consultez ce marqueur avant de renvoyer la commande. Sans marqueur, « cela n'a probablement pas été exécuté » n'est pas une stratégie de récupération.

## Faites correspondre les tentatives dans une portée d'intention limitée

Une empreinte seule fera correspondre trop d'actions légitimes. Limitez-la à la période et au contexte dans lesquels une nouvelle tentative est raisonnablement possible.

La portée la plus simple est une exécution d'agent. Si le même processus signé envoie la même action alors que son premier résultat n'est pas résolu, considérez la seconde requête comme une tentative potentielle. Si un autre processus l'envoie plusieurs heures plus tard, considérez-la comme une nouvelle intention, sauf si l'action possède elle-même une clé d'idempotence durable.

Une bonne règle de correspondance ressemble à ceci :

```text
if prior.fingerprint == incoming.fingerprint
  and prior.actor == incoming.actor
  and prior.intent_scope == incoming.intent_scope
  and prior.state in {accepted, authorized, dispatched, completed_result_not_delivered}:
    recover_or_attach_to(prior.execution_id)
else:
    create_new_execution()
```

`recover_or_attach_to` ne doit pas renvoyer aveuglément une réussite. Son comportement dépend de l'état précédent.

Si l'exécution précédente est acceptée mais pas envoyée, la passerelle peut poursuivre cette exécution. Si elle a été envoyée avec un résultat inconnu, la passerelle doit consulter l'extrémité d'état de la cible, son mécanisme d'idempotence ou son marqueur durable. Si elle est terminée mais que la livraison du résultat a échoué, elle doit renvoyer la référence du résultat enregistré. Si elle a été rejetée par l'autorisation, elle doit renvoyer ce rejet plutôt que créer un nouveau processus d'approbation à partir de la même nouvelle tentative ambiguë.

La portée doit correspondre à l'action. Une fenêtre de cinq minutes peut convenir à une requête d'API qui expire. Elle ne suffit pas pour un déploiement logiciel qui dure une heure. Un renouvellement d'identifiant peut nécessiter une empreinte durable jusqu'à la vérification de l'identifiant actif. N'utilisez pas une durée de vie globale simplement parce qu'elle est facile à configurer. Définissez des règles de conservation et de récupération propres à chaque action.

## L'approbation est un indice, pas un mécanisme d'idempotence

Une approbation humaine peut établir qu'un processus était autorisé à tenter une action. Elle ne peut pas prouver qu'une tentative précédente a déjà eu lieu.

Cela compte pour les systèmes qui demandent une confirmation à chaque appel sensible. Supposons qu'un agent demande le renouvellement d'un identifiant de production. Une personne approuve. La passerelle envoie la requête, puis le client se déconnecte. L'agent se reconnecte et génère le même appel d'outil. Demander une seconde approbation crée un choix trompeur. L'opérateur voit une requête familière et peut l'approuver, mais la question à résoudre est de savoir si le premier renouvellement a abouti.

L'approbation à chaque appel conserve son utilité. Elle contrôle l'autorisation au moment de l'utilisation. Séparez-la de la gestion des doublons :

- L'autorisation décide si l'appelant actuel peut lancer une exécution.
- L'empreinte décide si une requête entrante correspond à une exécution existante.
- Les relevés d'activité décident si cette exécution peut être reprise, récupérée ou doit être examinée.

Lorsqu'une nouvelle tentative correspond à une exécution en attente, affichez le relevé d'activité d'origine au lieu de présenter une nouvelle approbation comme si rien ne s'était passé. Le réviseur doit voir la cible, l'heure du premier envoi, le résultat connu et la raison pour laquelle la passerelle n'a pas renvoyé l'action.

Sallyport utilise une échelle de décision fixe : un coffre verrouillé refuse les actions, un nouveau processus agent reçoit par défaut une autorisation par session et certains identifiants peuvent exiger une approbation à chaque utilisation. Ces contrôles répondent à la question de savoir qui peut agir. L'enregistrement d'exécution doit encore répondre à celle de savoir si l'action a déjà franchi la frontière.

## Les clés d'idempotence HTTP ne résolvent qu'une partie du problème

Si une API en amont accepte les clés d'idempotence, utilisez-les. Envoyez une valeur stable pendant toute la durée d'une exécution, conservez la réponse de la cible et réutilisez cette valeur uniquement pour récupérer la même exécution.

Par exemple, la passerelle peut créer un identifiant d'exécution avant l'envoi et l'associer à l'en-tête attendu par l'API :

```http
POST /v1/releases HTTP/1.1
Host: deploy-api.internal.example
Idempotency-Key: act_01J4K8J7DX7V
Content-Type: application/json

{"service":"catalog","version":"2026.07.22","region":"us-east-1"}
```

L'API doit définir ce qu'elle fait lorsque cet en-tête se répète. Le meilleur comportement consiste à renvoyer le résultat d'origine pour la même requête sémantique et à rejeter une requête différente qui tente de réutiliser la même valeur. Si elle accepte silencieusement un corps modifié avec la même clé, votre passerelle ne peut rien déduire de fiable d'une répétition.

N'utilisez pas l'empreinte comme clé d'idempotence externe si elle peut persister entre des actions intentionnelles. Un identifiant d'exécution est unique pour une tentative acceptée. L'empreinte localise une tentative potentiellement liée. Ces deux éléments ont des rôles différents.

L'idempotence HTTP ne fait rien pour SSH à elle seule. Il vous faut un protocole distant. Une méthode sûre consiste à transmettre un identifiant d'exécution généré à un script qui écrit un état durable sur l'hôte ou dans un espace partagé, puis refuse de démarrer deux fois la même opération. Si vous ne pouvez pas modifier la commande ni vérifier un marqueur externe, classez-la parmi les actions irréversibles et exigez une vérification après une déconnexion incertaine.

## Analysez la séquence, pas le nombre final

Deux lignes d'activité contenant les mêmes arguments ne prouvent pas un doublon. Commencez par la séquence des événements et suivez le premier appel jusqu'à sa frontière d'envoi.

Une véritable analyse doit répondre aux questions suivantes dans cet ordre :

1. Un seul processus agent ou deux processus distincts ont-ils envoyé les appels ?
2. Le premier appel a-t-il reçu l'autorisation et atteint l'étape d'envoi ?
3. La passerelle a-t-elle reçu une réponse ou un code de sortie de la cible ?
4. La livraison du résultat a-t-elle échoué après la fin de l'action sur la cible ?
5. Le second appel a-t-il réutilisé l'identifiant d'exécution d'origine, porté une clé d'idempotence ou créé une nouvelle tentative ?

Cet ordre évite une conclusion fréquente et erronée : « Les journaux montrent deux appels, donc l'agent a agi deux fois. » Vous pouvez découvrir que la passerelle a enregistré une exécution terminée et une nouvelle tentative du client qui s'y est rattachée. Vous pouvez aussi découvrir deux processus autorisés distincts, chacun avec son propre contexte de planification, qui ont tous deux envoyé l'action. Les corrections à apporter ne sont pas les mêmes.

Conservez l'espace d'activité en ajout uniquement ou rendez-le infalsifiable d'une autre manière. Les analyses de doublons surviennent souvent après un incident coûteux, lorsque quelqu'un souhaite une histoire plus nette que celle que le système peut réellement étayer. Un enregistrement chaîné par hachage ne rend pas la décision initiale correcte, mais il rend sa reconstitution ultérieure plus difficile à manipuler.

Ne cachez pas non plus l'ambiguïté à l'agent. Renvoyez un résultat indiquant que l'exécution précédente attend une vérification ou qu'elle est terminée mais que la livraison du résultat a été interrompue. Un modèle qui voit un échec fabriqué essaiera à nouveau. Un modèle qui voit un état incertain explicite peut consulter l'état, demander une vérification ou choisir une voie plus sûre.

## Intégrez le comportement en cas de répétition dans chaque contrat d'outil

Chaque outil ayant un effet de bord doit répondre explicitement à une question : que se passe-t-il lorsque l'appelant perd la réponse après l'envoi ?

Écrivez la réponse à côté de la définition de l'outil. Indiquez si l'action est en lecture seule, répétable avec une identité d'idempotence, récupérable par une consultation d'état ou bloquée après un résultat inconnu. Précisez ce qui doit entrer dans son empreinte et pendant combien de temps une exécution inachevée peut encore être rattachée. Si personne ne peut l'écrire, l'outil n'est pas prêt pour une utilisation autonome.

Le travail d'ingénierie reste généralement modeste par rapport au nettoyage nécessaire après un double déploiement, un double compte, un double paiement ou un second renouvellement d'identifiant. Ajoutez l'identifiant d'exécution avant l'appel à la cible. Enregistrez les changements d'état avant et après l'envoi. Conservez une référence du résultat. Puis faites en sorte que le code de reconnexion consulte cet enregistrement avant de toucher à nouveau au monde extérieur.

C'est la règle à retenir : un transport interrompu peut interrompre une conversation, mais il ne doit jamais transformer silencieusement l'incertitude en seconde action.
