Voici l'outil qu'utilise un chat Claude quand il veut placer des fichiers dans un dépôt GitNest via le connecteur MCP d'osFoundry. Il provient du tools/list public de l'endpoint en production, récupéré avec curl le 2026-11-06 (aucun token n'est nécessaire pour le catalogue) :
$ curl -s -X POST https://api.osfoundry.io/api/v1/external-mcp \ -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' {"name": "propose_gitnest_file_import", "description": "Prepare up to 20 UTF-8 files (200 KB total) for a native GitNest repository branch. The owner reviews every file and approves the commit in osFoundry. Repository GitNest must be enabled.", "inputSchema": {"required": ["repository_id", "branch", "message", "files", "idempotency_key"], "properties": { "files": {"minItems": 1, "maxItems": 20, "items": {"properties": {"path": {"maxLength": 2048}, "content": {"maxLength": 100000}}}}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}}, "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} Regardez les verbes. L'outil « prépare », le propriétaire « approuve le commit ». L'appel rend la main avant que quoi que ce soit ne touche Git, et aucun autre outil du connecteur ne le touche non plus. L'idempotency_key est obligatoire, pas optionnelle, et a une longueur minimale : un client ne peut donc pas envoyer une clé vide ou d'un seul caractère et appeler ça idempotent.
La suite de l'article explique pourquoi cette forme est la bonne pour un agent et ce que le serveur doit faire pour qu'elle reste fiable. Ensuite, elle reconstruit la plus petite version possible sur un vrai dépôt Git et mesure ce qui casse quand on retire des morceaux.
Que fixent les sept outils de proposition ?
GitNest expose 13 outils sur le connecteur. Six ne font que lire : read_gitnest_repository, search_gitnest_code, list_gitnest_work, read_gitnest_work, list_gitnest_saved_results et list_gitnest_checks. Les sept autres commencent par propose_. Leurs champs obligatoires montrent ce que chacun fige au moment où le modèle l'appelle :
| Outil | idempotency_key | expected_change_set_version | expected_composition_oid | openWorldHint |
|---|---|---|---|---|
propose_gitnest_work | obligatoire | non | non | false |
propose_gitnest_change_set | obligatoire | non | non | false |
propose_gitnest_server_composition | obligatoire | obligatoire | non | false |
propose_gitnest_file_import | obligatoire | non | non | false |
propose_gitnest_check_rerun | obligatoire | obligatoire | obligatoire | false |
propose_gitnest_native_publication | obligatoire | obligatoire | obligatoire | true |
propose_gitnest_provider_publication | obligatoire | obligatoire | obligatoire | true |
Ce qui est figé augmente avec les conséquences. Une tâche ne fige que sa clé. La composition fige la version du change set que le modèle a lue. Relancer un check ou publier une pull request fige la version et le commit composé exact. Les deux outils de publication sont marqués openWorldHint: true, car ils produisent quelque chose en dehors du stockage des propositions : une pull request et, pour l'outil fournisseur, une branche poussée vers GitHub ou GitLab.
Dans la carte de revue du propriétaire, ces valeurs figées deviennent du texte : "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…". Ce que le propriétaire approuve, c'est cette version et ce commit, pas ce qui est à jour au moment où le clic arrive.
Pourquoi ne pas donner un token de push à l'agent ?
Parce qu'un push est décidé au moment où le modèle l'émet, alors qu'une proposition est décidée quand une personne disposant de droits à jour la regarde.
Le code d'osFoundry trace cette limite explicitement. Les routes d'écriture de GitNest sont protégées par denyAgentScope, et le pont de Maestro vers GitNest dans l'application n'autorise que la lecture. Les outils connectés ne contournent pas ça avec un token de navigateur emprunté. Chaque proposition est stockée, montrée au propriétaire de la connexion, puis exécutée après approbation par le propre writer d'osFoundry, attribuée à la connexion. Une conversation Claude ne détient jamais d'identifiant capable d'écrire dans le dépôt, et ne voit jamais le token GitHub ou GitLab du propriétaire. La publication vers le fournisseur ne pousse que la branche dédiée du change set, avec un lease sur ref absente, et ne réplique jamais de refs sans rapport.
Le prix de ce choix, c'est le temps. L'écart entre « proposé » et « approuvé » est du temps humain : quelques minutes, parfois une journée. Pendant cette fenêtre, la branche avance, quelqu'un recompose le change set, un check se termine, le propriétaire perd son rôle sur le dépôt, la connexion est révoquée. L'approbation d'osFoundry revérifie la connexion, l'appartenance au projet, le rôle actuel du propriétaire sur le dépôt, l'expiration et, pour les actions sur un change set, la version et le commit exacts, et pour la publication la politique de risque et les checks requis. "A declined, expired, or stale proposal must not execute" est une ligne de son plan.
Il reste deux modes de défaillance, et ce sont eux que la reconstruction étudie :
- Deux approbations sur la même base. Chacune, prise seule, a l'air correcte.
- Un retry après une défaillance partielle. Git a le commit, la base de données ne le sait pas encore, et le propriétaire clique à nouveau.
Pour le second, le plan d'osFoundry décrit le correctif livré dans le service Git : les imports de fichiers passent l'ID immuable de la proposition comme clé d'idempotence, et le service "updates the branch and replay marker atomically with compare-and-swap, so a database failure after the Git commit can be retried without another commit."
La reconstruction
Deux fichiers, bibliothèque standard plus la CLI git (Apple Git 2.39.5), exécutés sur un Mac Apple M1 qui faisait aussi du rendu vidéo pour d'autres tâches :
-
server.py, 225 lignes : un endpoint JSON-RPC au-dessus d'un dépôt Git bare et d'un fichier sqlite. -
client.py, 203 lignes : un « Claude » scripté et un propriétaire scripté.
Aucun binaire ni compte Claude n'a été utilisé. Le client envoie le même type de requêtes tools/call qu'un chat connecté. Les outils de la reconstruction sont read_repo, propose_file_import et read_proposal. Ces noms sont propres à la reconstruction, et aucun d'eux n'écrit dans Git. Le propriétaire décide sur une route séparée, /owner/decide, qui répond à un token d'agent par 403 agent_scope_denied. C'est l'idée de denyAgentScope en un seul if.
Une différence volontaire : la reconstruction fige aussi un commit de base pour les imports de fichiers (expected_base_oid, par défaut la pointe de la branche au moment de la proposition). Le schéma de propose_gitnest_file_import d'osFoundry n'a pas ce champ. Considérez cette partie comme un choix de conception de la reconstruction, pas comme une description d'osFoundry.
La réception des propositions est idempotente sur (connection, idempotency_key), avec un hash du corps pour qu'une clé réutilisée avec un autre corps soit une erreur et non un replay silencieux :
c.execute("INSERT OR IGNORE INTO proposals (id,conn,idem_key,body_hash,branch,base_oid,files," "message,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,'pending',?,?)", (...)) row = c.execute("SELECT * FROM proposals WHERE conn=? AND idem_key=?", (conn, key)).fetchone() if row["body_hash"] != h: raise Conflict("idempotency_key_reused_with_different_body") L'approbation construit le commit uniquement avec des commandes plumbing (read-tree dans un index temporaire, hash-object -w, update-index --cacheinfo, write-tree, commit-tree -p <base>). Rien de tout ça ne déplace une ref. Ensuite, une seule transaction déplace la branche uniquement si elle pointe toujours sur la base revue, et crée une ref marqueur portant le nom de la proposition :
tx = (f"start\nupdate refs/heads/{branch} {new} {base_oid}\n" f"create refs/proposals/{proposal_id} {new}\nprepare\ncommit\n") git("update-ref", "--stdin", inp=tx) update avec une ancienne valeur, c'est du compare-and-swap. create échoue si la ref existe déjà. Git applique les deux ou aucune. Avant de construire quoi que ce soit, l'approbation cherche refs/proposals/<id>. Si elle existe, une tentative précédente a déjà atteint Git : elle enregistre donc ce commit et le renvoie comme replay.
Ce qu'a vu le Claude scripté
python3 client.py scenario, avec les UUID des propositions raccourcis. La ligne d'expiration vient d'un second serveur lancé avec un TTL de 0.3 s :
read_repo -> head 29a940e8 (1 commit) propose A README.md key=readme-fix-0001 -> {"proposal_id": "96dcf05f-…", "status": "pending", "base_oid": "29a940e8"} propose A (retry, same key+body) -> {"proposal_id": "96dcf05f-…", "status": "pending", "base_oid": "29a940e8", "replayed": true} propose A' (same key, other body) -> idempotency_key_reused_with_different_body propose B app.py key=app-bump-0001 -> {"proposal_id": "c91824e3-…", "status": "pending", "base_oid": "29a940e8"} decide A with the agent token -> HTTP 403 {'error': 'agent_scope_denied'} owner approves A -> HTTP 200 {"status": "executed", "commit": "f07b9fda"} (2 commits) owner approves B -> HTTP 200 {"status": "stale", "refused": true, "base_oid": "29a940e8", "branch_head": "f07b9fda"} (2 commits) propose B2 on new head f07b9fda -> {"proposal_id": "bc3cedfc-…", "status": "pending", "base_oid": "f07b9fda"} owner approves B2, crash before DB -> HTTP 500 {'error': 'injected: crash after git, before DB'} (3 commits) owner retries approval of B2 -> HTTP 200 {"status": "executed", "commit": "fb736eda", "replayed": true} (3 commits) owner clicks approve on A again -> HTTP 200 {"status": "executed", "commit": "f07b9fda", "replayed": true} (3 commits) approve after decline -> HTTP 200 {'status': 'declined', 'refused': True} (3 commits) approve after expiry (ttl 0.3 s) -> HTTP 200 {'status': 'expired', 'refused': True} (1 commit) fb736ed Claude (conn-claude-research) | repo-owner | Bump app f07b9fd Claude (conn-claude-research) | repo-owner | Pin install version 29a940e owner | owner | seed B modifiait app.py et A modifiait README.md, et B a quand même été refusée. C'est la lecture stricte de « le propriétaire a revu ceci par rapport à 29a940e8 ». Le correctif ne coûte pas cher : le client relit la nouvelle pointe et propose à nouveau, et c'est exactement ce qu'est B2. La ligne git log montre la provenance écrite par le stockage. L'auteur est la connexion, le committer est la personne qui a approuvé, et le message porte les trailers Proposal: <id> et Approved-by:.
Qu'est-ce qui casse sans compare-and-swap ?
--mode naive garde tout sauf la transaction. Il vérifie la pointe en Python, construit le commit, puis exécute git update-ref refs/heads/main <new> sans ancienne valeur ni marqueur. Même serveur, même client, même organisation du dépôt.
Test de concurrence : deux propositions différentes sur la même base, approuvées au même instant depuis deux threads (threading.Barrier), 100 essais par exécution, deux exécutions. Après chaque essai, le client demande à git merge-base --is-ancestor si chaque commit signalé comme exécuté est réellement sur main.
Test de crash : approuver avec une panne injectée après l'étape Git et avant la mise à jour de la base de données, puis approuver de nouveau. 50 essais par exécution, deux exécutions. Le client compte les commits sur main avant et après.
| Mode | Concurrence : les deux signalées "executed" | Concurrence : commits approuvés absents de main | Crash, puis retry | Commits en trop après le retry |
|---|---|---|---|---|
| CAS + marqueur, exécution 1 | 0 / 100 | 0 | 50 × executed (replayed) | 0 |
| CAS + marqueur, exécution 2 | 0 / 100 | 0 | 50 × executed (replayed) | 0 |
| naive, exécution 1 | 100 / 100 | 100 | 50 × stale | 0 |
| naive, exécution 2 | 100 / 100 | 100 | 50 × stale | 0 |
La concurrence en mode naive a échoué à chaque essai, pas de temps en temps. La fenêtre entre « la pointe égale la base » et « écrire la ref » dure quatre processus git, des dizaines de millisecondes, et deux threads libérés par une barrière tombent tous les deux dedans à chaque fois. Les deux approbations renvoient executed avec un ID de commit. Le propriétaire voit deux résultats verts, et l'un des deux commits n'est pas dans l'historique de la branche. Aucune erreur n'apparaît. Avec le compare-and-swap, chaque essai s'est terminé avec exactement une proposition exécutée et un refus stale indiquant la nouvelle head.
Le retry après crash en mode naive n'a pas créé de doublon. La vérification de la base l'a intercepté, puisque la branche avait bougé. Mais elle l'a intercepté comme stale. Le commit de la proposition elle-même est sur main, et le stockage signale désormais cette proposition comme refusée. Si vous supprimez la vérification de la base pour faire disparaître ça, le retry écrit le commit une deuxième fois. C'est le marqueur de replay qui permet au retry de dire « c'est déjà fait, voici le commit ».
Combien ça coûte ?
Temps réel par requête côté client sur HTTP en loopback (urllib, nouvelle connexion à chaque fois), n = 200 par opération et par exécution, deux exécutions. Les plages vont de l'exécution 1 à l'exécution 2.
| Opération | CAS médiane | CAS p95 | naive médiane | naive p95 |
|---|---|---|---|---|
| propose (nouvelle clé) | 4.42–4.44 ms | 7.1–12.3 ms | 4.08–4.66 ms | 6.4–10.0 ms |
| propose (même clé, replay) | 1.42–1.50 ms | 3.3–4.5 ms | 1.27–1.49 ms | 2.4–4.1 ms |
| approve, exécutée | 115–119 ms | 199–257 ms | 92–105 ms | 130–193 ms |
| approve, refusée comme stale | 28–31 ms | 52–70 ms | 14–17 ms | 24–34 ms |
| approve à nouveau, déjà exécutée | 1.46–1.60 ms | 3.4–4.7 ms | 1.38–1.54 ms | 2.6–4.2 ms |
Presque tout vient du lancement de processus. Une approbation CAS qui s'exécute lance huit processus git (recherche du marqueur, recherche de la pointe, read-tree, hash-object, update-index, write-tree, commit-tree, update-ref --stdin), la version naive six. Un refus stale, c'est deux recherches contre une. À environ 14 ms par processus git sur cette machine chargée, le chemin sûr coûte à peu près deux lancements de plus, autour de 15–25 ms, sur une requête qui attend de toute façon le clic d'un humain. Un serveur qui lie libgit2 au lieu d'appeler le shell réduirait toutes ces colonnes. L'écart entre elles se limiterait toujours aux deux recherches.
Les replays sont le chemin le moins cher dans les deux tableaux, une seule lecture sqlite. Un client qui fait un retry coûte moins cher au serveur qu'une nouvelle requête.
Si vous le mettez en place vous-même
- Ne donnez à l'agent aucun outil d'écriture sur le dépôt, seulement
propose_*, et vérifiez sur la route de décision que l'appelant n'est pas un token d'agent. Ne comptez pas sur le fait que l'agent ne trouvera pas la route. - Figez dans la proposition ce que le relecteur a vu, version et commit, et comparez-le à l'approbation. Refusez en renvoyant la head actuelle dans la réponse, pour que le client puisse relire et proposer à nouveau.
- Faites de l'écriture Git une seule transaction de refs : mise à jour de la branche avec l'ancienne valeur, plus un marqueur en création seule portant le nom de la proposition. Cherchez le marqueur avant de construire quoi que ce soit.
- Exigez une clé d'idempotence avec une longueur minimale, stockez-la avec un hash du corps et rejetez une clé réutilisée avec un autre corps.
- Écrivez la provenance dans le commit lui-même. L'auteur est la connexion, le committer est l'approbateur, et un trailer porte l'ID de la proposition.
git logl'aura toujours quand votre base de données aura disparu. - Arrêtez-vous à la pull request. Le merge et le déploiement reviennent au propriétaire du dépôt, sur son propre écran.
La partie GitNest, clone, push et tokens d'accès, est décrite sur la page GitNest d'osFoundry. Le connecteur parle MCP standard, donc les mêmes outils de proposition fonctionnent depuis Claude Code ou n'importe quel autre hôte MCP.
