Questo è il tool che usa una chat di Claude quando vuole mettere dei file in un repository GitNest tramite il connettore MCP di osFoundry. Viene dal tools/list pubblico dell'endpoint in produzione, recuperato con curl il 2026-11-06 (per il catalogo non serve un token):
$ 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"]}]} Guarda i verbi. Il tool "prepara", il proprietario "approva il commit". La chiamata ritorna prima che qualcosa tocchi Git, e sul connettore non c'è nessun altro tool che lo tocchi. L'idempotency_key è obbligatoria, non opzionale, e ha una lunghezza minima, quindi un client non può mandare una chiave vuota o di un solo carattere e chiamarla idempotente.
Il resto del post spiega perché questa forma è quella giusta per un agente e cosa deve fare il server per mantenerla affidabile. Poi ricostruisce la versione più piccola su un vero repository Git e misura cosa va storto quando togli dei pezzi.
Cosa fissano i sette tool di proposta?
GitNest ha 13 tool sul connettore. Sei leggono soltanto: read_gitnest_repository, search_gitnest_code, list_gitnest_work, read_gitnest_work, list_gitnest_saved_results e list_gitnest_checks. Gli altri sette iniziano con propose_. I loro campi obbligatori mostrano cosa fissa ciascuno nel momento in cui il modello lo chiama:
| Tool | idempotency_key | expected_change_set_version | expected_composition_oid | openWorldHint |
|---|---|---|---|---|
propose_gitnest_work | obbligatorio | no | no | false |
propose_gitnest_change_set | obbligatorio | no | no | false |
propose_gitnest_server_composition | obbligatorio | obbligatorio | no | false |
propose_gitnest_file_import | obbligatorio | no | no | false |
propose_gitnest_check_rerun | obbligatorio | obbligatorio | obbligatorio | false |
propose_gitnest_native_publication | obbligatorio | obbligatorio | obbligatorio | true |
propose_gitnest_provider_publication | obbligatorio | obbligatorio | obbligatorio | true |
Quello che viene fissato cresce con le conseguenze. Un task fissa solo la sua chiave. La composizione fissa la versione del change set che il modello ha letto. Rilanciare un check o pubblicare una pull request fissa la versione e il commit composto esatto. Entrambi i tool di pubblicazione sono marcati openWorldHint: true, perché producono qualcosa fuori dall'archivio delle proposte: una pull request e, per il tool del provider, un branch pushato su GitHub o GitLab.
Nella scheda di revisione del proprietario questi valori fissati diventano testo: "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…". Quello che il proprietario approva è quella versione e quel commit, non ciò che è attuale quando arriva il clic.
Perché non dare all'agente un token di push?
Perché un push viene deciso quando il modello lo emette, mentre una proposta viene decisa quando la guarda una persona con permessi aggiornati.
Il codice di osFoundry traccia questa linea in modo esplicito. Le route di scrittura di GitNest sono protette da denyAgentScope, e il bridge di Maestro verso GitNest dentro l'app consente solo letture. I Connected tool non aggirano questo limite con un token del browser preso in prestito. Ogni proposta viene salvata, mostrata al proprietario della connessione ed eseguita dopo l'approvazione dal writer di osFoundry stesso, attribuita alla connessione. Una conversazione di Claude non possiede mai una credenziale in grado di scrivere nel repository, e non vede mai il token GitHub o GitLab del proprietario. La pubblicazione verso il provider pusha solo il branch dedicato del change set, con un lease su ref assente, e non replica mai ref estranee.
Il prezzo di questa scelta è il tempo. Lo scarto tra "proposto" e "approvato" è tempo umano: minuti, a volte un giorno. In quella finestra il branch avanza, qualcuno ricompone il change set, un check termina, il proprietario perde il suo ruolo sul repository, la connessione viene revocata. L'approvazione di osFoundry ricontrolla la connessione, l'appartenenza al progetto, il ruolo attuale del proprietario sul repository, la scadenza e, per le azioni sui change set, la versione e il commit esatti, e per la pubblicazione la policy di rischio e i check obbligatori. "A declined, expired, or stale proposal must not execute" è una riga del suo piano.
Restano due modi di fallire, e sono il tema della ricostruzione:
- Due approvazioni sulla stessa base. Ognuna, presa da sola, sembra a posto.
- Un retry dopo un errore parziale. Git ha il commit, il database non lo sa ancora e il proprietario clicca di nuovo.
Per il secondo, il piano di osFoundry descrive la correzione rilasciata nel servizio Git: gli import di file passano l'ID immutabile della proposta come chiave di idempotenza, e il servizio "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 ricostruzione
Due file, libreria standard più la CLI di git (Apple Git 2.39.5), eseguiti su un Mac Apple M1 che nel frattempo renderizzava video per altri job:
-
server.py, 225 righe: un endpoint JSON-RPC sopra un repository Git bare e un file sqlite. -
client.py, 203 righe: un "Claude" scriptato e un proprietario scriptato.
Non sono stati usati né un binario né un account Claude. Il client invia lo stesso tipo di richieste tools/call che manderebbe una chat connessa. I tool della ricostruzione sono read_repo, propose_file_import e read_proposal. Questi nomi sono propri della ricostruzione, e nessuno di essi scrive su Git. Il proprietario decide su una route separata, /owner/decide, che risponde a un token di agente con 403 agent_scope_denied. È l'idea di denyAgentScope in un solo if.
Una differenza voluta: la ricostruzione fissa un commit di base anche sugli import di file (expected_base_oid, che per default è la punta del branch al momento della proposta). Lo schema di propose_gitnest_file_import di osFoundry non ha questo campo. Considera questa parte una scelta di design della ricostruzione, non una descrizione di osFoundry.
La ricezione delle proposte è idempotente su (connection, idempotency_key), con un hash del body, così una chiave riusata con un body diverso è un errore e non un replay silenzioso:
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'approvazione costruisce il commit solo con comandi plumbing (read-tree in un indice temporaneo, hash-object -w, update-index --cacheinfo, write-tree, commit-tree -p <base>). Niente di tutto questo sposta una ref. Poi una sola transazione sposta il branch solo se punta ancora alla base revisionata, e crea una ref marker con il nome della proposta:
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 con un valore vecchio è compare-and-swap. create fallisce se la ref esiste già. Git applica entrambe o nessuna. Prima di costruire qualsiasi cosa, l'approvazione cerca refs/proposals/<id>. Se c'è, un tentativo precedente ha già raggiunto Git, quindi registra quel commit e lo restituisce come replay.
Cosa ha visto il Claude scriptato
python3 client.py scenario, con gli UUID delle proposte accorciati. La riga della scadenza viene da un secondo server avviato con un TTL di 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 toccava app.py e A toccava README.md, eppure B è stata rifiutata. È la lettura rigorosa di "il proprietario ha revisionato questo rispetto a 29a940e8". La correzione costa poco: il client legge la nuova punta e propone di nuovo, ed è proprio questo B2. La riga di git log mostra la provenienza che scrive l'archivio. L'autore è la connessione, il committer è la persona che ha approvato, e il messaggio contiene i trailer Proposal: <id> e Approved-by:.
Cosa si rompe senza compare-and-swap?
--mode naive mantiene tutto tranne la transazione. Controlla la punta in Python, costruisce il commit e poi esegue git update-ref refs/heads/main <new> senza valore vecchio e senza marker. Stesso server, stesso client, stessa struttura del repository.
Test di race: due proposte diverse sulla stessa base, approvate nello stesso istante da due thread (threading.Barrier), 100 prove per run, due run. Dopo ogni prova il client chiede a git merge-base --is-ancestor se ogni commit segnalato come eseguito è davvero su main.
Test di crash: approvare con un errore iniettato dopo il passo Git e prima dell'aggiornamento del database, poi approvare di nuovo. 50 prove per run, due run. Il client conta i commit su main prima e dopo.
| Modalità | Race: entrambe segnalate "executed" | Race: commit approvati non su main | Crash, poi retry | Commit in più dopo il retry |
|---|---|---|---|---|
| CAS + marker, run 1 | 0 / 100 | 0 | 50 × executed (replayed) | 0 |
| CAS + marker, run 2 | 0 / 100 | 0 | 50 × executed (replayed) | 0 |
| naive, run 1 | 100 / 100 | 100 | 50 × stale | 0 |
| naive, run 2 | 100 / 100 | 100 | 50 × stale | 0 |
La race in modalità naive è fallita in ogni prova, non ogni tanto. La finestra tra "la punta è uguale alla base" e "scrivi la ref" dura quattro processi git, decine di millisecondi, e due thread rilasciati da una barriera ci finiscono dentro entrambi ogni volta. Tutte e due le approvazioni restituiscono executed con un ID di commit. Il proprietario vede due risultati verdi, e uno dei due commit non è nella history del branch. Nessun errore. Con compare-and-swap, ogni prova si è chiusa con esattamente una proposta eseguita e un rifiuto stale che indicava la nuova head.
Il retry dopo il crash in modalità naive non ha duplicato il commit. Lo ha bloccato il controllo sulla base, perché il branch si era spostato. Ma lo ha bloccato come stale. Il commit della proposta stessa è su main, e l'archivio ora riporta quella proposta come rifiutata. Se togli il controllo sulla base per far sparire il problema, il retry scrive il commit una seconda volta. È il marker di replay che permette al retry di dire "è già successo, ecco il commit".
Quanto costa?
Tempo reale per richiesta dal client su HTTP in loopback (urllib, nuova connessione ogni volta), n = 200 per operazione per run, due run. Gli intervalli vanno dal run 1 al run 2.
| Operazione | CAS mediana | CAS p95 | naive mediana | naive p95 |
|---|---|---|---|---|
| propose (chiave nuova) | 4.42–4.44 ms | 7.1–12.3 ms | 4.08–4.66 ms | 6.4–10.0 ms |
| propose (stessa chiave, replay) | 1.42–1.50 ms | 3.3–4.5 ms | 1.27–1.49 ms | 2.4–4.1 ms |
| approve, viene eseguita | 115–119 ms | 199–257 ms | 92–105 ms | 130–193 ms |
| approve, rifiutata come stale | 28–31 ms | 52–70 ms | 14–17 ms | 24–34 ms |
| approve di nuovo, già eseguita | 1.46–1.60 ms | 3.4–4.7 ms | 1.38–1.54 ms | 2.6–4.2 ms |
Quasi tutto è avvio di processi. Un'approvazione CAS che viene eseguita lancia otto processi git (lookup del marker, lookup della punta, read-tree, hash-object, update-index, write-tree, commit-tree, update-ref --stdin), quella naive sei. Un rifiuto stale sono due lookup contro uno. A circa 14 ms per processo git su questa macchina carica, il percorso sicuro costa circa due avvii in più, intorno ai 15–25 ms, su una richiesta che comunque aspetta il clic di una persona. Un server che linka libgit2 invece di passare dalla shell accorcerebbe tutte queste colonne. La differenza tra di esse resterebbe i due lookup.
I replay sono il percorso più economico in entrambe le tabelle, una sola lettura sqlite. Un client che fa retry costa al server meno di una richiesta nuova.
Se lo implementi tu
- Non dare all'agente nessun tool di scrittura sul repository, solo
propose_*, e controlla sulla route di decisione che chi chiama non sia un token di agente. Non contare sul fatto che l'agente non trovi la route. - Fissa nella proposta quello che ha visto il revisore, versione e commit, e confrontalo al momento dell'approvazione. Rifiuta restituendo la head attuale nella risposta, così il client può rileggere e proporre di nuovo.
- Fai della scrittura su Git una sola transazione di ref: aggiornamento del branch con il valore vecchio, più un marker solo-creazione con il nome della proposta. Cerca il marker prima di costruire qualsiasi cosa.
- Richiedi una chiave di idempotenza con lunghezza minima, salvala con un hash del body e rifiuta una chiave riusata con un body diverso.
- Scrivi la provenienza nel commit stesso. L'autore è la connessione, il committer è chi approva e un trailer contiene l'ID della proposta.
git logce l'avrà ancora quando il tuo database non ci sarà più. - Fermati alla pull request. Merge e deploy spettano a chi possiede il repository, sul suo schermo.
La parte GitNest, cioè clone, push e token di accesso, è descritta nella pagina GitNest di osFoundry. Il connettore parla MCP standard, quindi gli stessi tool di proposta funzionano da Claude Code o da qualsiasi altro host MCP.