Ini adalah tool yang dipakai chat Claude saat ingin memasukkan file ke repository GitNest lewat MCP connector osFoundry. Tool ini diambil dari tools/list publik milik endpoint live, di-fetch dengan curl pada 2026-11-06 (katalognya bisa dibaca tanpa 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"]}]} Perhatikan kata kerjanya. Tool ini "prepares", sedangkan owner yang "approves the commit". Call-nya sudah return sebelum ada apa pun yang menyentuh Git, dan tidak ada tool lain di connector ini yang menyentuh Git. idempotency_key wajib diisi, bukan opsional, dan punya panjang minimum, jadi client tidak bisa mengirim key kosong atau satu karakter lalu menyebutnya idempotent.
Sisa tulisan ini membahas kenapa bentuk seperti itu tepat untuk agent, dan apa yang harus dilakukan server supaya bentuk itu tetap jujur. Setelah itu, versi paling kecilnya dibangun ulang di atas repository Git sungguhan, lalu diukur apa yang rusak kalau beberapa bagiannya dihilangkan.
Apa yang dikunci oleh tujuh tool propose?
GitNest punya 13 tool di connector. Enam di antaranya hanya membaca: read_gitnest_repository, search_gitnest_code, list_gitnest_work, read_gitnest_work, list_gitnest_saved_results dan list_gitnest_checks. Tujuh sisanya diawali propose_. Field wajibnya menunjukkan apa yang dikunci masing-masing tool pada saat model memanggilnya:
| Tool | idempotency_key | expected_change_set_version | expected_composition_oid | openWorldHint |
|---|---|---|---|---|
propose_gitnest_work | wajib | tidak | tidak | false |
propose_gitnest_change_set | wajib | tidak | tidak | false |
propose_gitnest_server_composition | wajib | wajib | tidak | false |
propose_gitnest_file_import | wajib | tidak | tidak | false |
propose_gitnest_check_rerun | wajib | wajib | wajib | false |
propose_gitnest_native_publication | wajib | wajib | wajib | true |
propose_gitnest_provider_publication | wajib | wajib | wajib | true |
Makin besar dampaknya, makin banyak yang dikunci. Sebuah task hanya mengunci key-nya. Composing mengunci versi change set yang dibaca model. Menjalankan ulang check atau memublikasikan pull request mengunci versi itu sekaligus commit hasil compose yang persis. Kedua tool publication ditandai openWorldHint: true, karena keduanya menghasilkan sesuatu di luar proposal store: sebuah pull request, dan untuk tool provider, sebuah branch yang di-push ke GitHub atau GitLab.
Di review card milik owner, kunci-kunci itu muncul sebagai teks: "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…". Yang di-approve owner adalah versi dan commit itu, bukan apa pun yang kebetulan terbaru saat klik-nya sampai.
Kenapa tidak memberi agent token push saja?
Karena push diputuskan saat model mengeluarkannya, sedangkan proposal diputuskan saat seseorang dengan hak akses yang masih berlaku melihatnya.
Kode osFoundry menarik garis itu secara eksplisit. Route write GitNest dijaga oleh denyAgentScope, dan bridge Maestro di dalam aplikasi ke GitNest hanya mengizinkan read. Connected tools tidak mengakali itu dengan token browser pinjaman. Setiap proposal disimpan, ditampilkan ke owner koneksi, lalu dieksekusi oleh writer milik osFoundry sendiri setelah di-approve, dengan atribusi ke koneksi tersebut. Percakapan Claude tidak pernah memegang credential yang bisa menulis ke repository, dan tidak pernah melihat token GitHub atau GitLab milik owner. Publication ke provider hanya mem-push branch khusus milik change set, dengan lease absent-ref, dan tidak pernah me-mirror ref lain yang tidak terkait.
Harga dari pilihan itu adalah waktu. Jeda antara "proposed" dan "approved" adalah waktu manusia: beberapa menit, kadang sehari. Dalam jeda itu branch bergerak, seseorang meng-compose ulang change set, sebuah check selesai, owner kehilangan role di repository, koneksinya dicabut. Approval di osFoundry mengecek ulang koneksi, keanggotaan project, role repository owner saat ini, masa berlaku, dan untuk aksi change set juga versi dan commit yang persis, serta untuk publication juga risk policy dan check yang diwajibkan. "A declined, expired, or stale proposal must not execute" adalah satu baris di plan-nya.
Tinggal dua mode kegagalan, dan keduanya jadi bahan rebuild:
- Dua approval terhadap base yang sama. Masing-masing terlihat baik-baik saja.
- Retry setelah kegagalan parsial. Git sudah punya commit-nya, database belum tahu, lalu owner klik lagi.
Untuk yang kedua, plan osFoundry menjelaskan perbaikan yang sudah dirilis di Git service: file import mengirim proposal ID yang immutable sebagai idempotency key, dan service tersebut "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."
Rebuild-nya
Dua file, standard library ditambah git CLI (Apple Git 2.39.5), dijalankan di Mac Apple M1 yang sedang merender video untuk job lain juga:
-
server.py, 225 baris: endpoint JSON-RPC di atas bare Git repository dan sebuah file sqlite. -
client.py, 203 baris: "Claude" yang di-script dan owner yang di-script.
Tidak ada binary Claude atau akun Claude yang terlibat. Client mengirim request tools/call yang sejenis dengan yang dikirim chat yang terhubung. Tool di rebuild ini adalah read_repo, propose_file_import dan read_proposal. Nama-nama itu buatan rebuild sendiri, dan tidak satu pun menulis ke Git. Owner memutuskan lewat route terpisah, /owner/decide, yang menjawab token agent dengan 403 agent_scope_denied. Itulah ide denyAgentScope dalam satu if.
Satu perbedaan yang disengaja: rebuild ini juga mengunci base commit pada file import (expected_base_oid, default-nya tip branch saat proposal dibuat). Schema propose_gitnest_file_import milik osFoundry tidak punya field seperti itu. Anggap bagian itu sebagai pilihan desain rebuild, bukan deskripsi osFoundry.
Intake proposal bersifat idempotent pada (connection, idempotency_key), dengan hash dari body, jadi key yang dipakai ulang dengan body berbeda menjadi error, bukan replay diam-diam:
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") Approval membangun commit hanya dengan plumbing (read-tree ke index sementara, hash-object -w, update-index --cacheinfo, write-tree, commit-tree -p <base>). Tidak ada satu pun dari itu yang menggeser ref. Lalu satu transaksi menggeser branch hanya jika branch itu masih menunjuk ke base yang di-review, sekaligus membuat marker ref yang dinamai sesuai proposal:
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 dengan old value adalah compare-and-swap. create gagal kalau ref-nya sudah ada. Git menerapkan keduanya atau tidak sama sekali. Sebelum membangun apa pun, approval mencari refs/proposals/<id>. Kalau ada, berarti percobaan sebelumnya sudah sampai ke Git, jadi approval mencatat commit itu dan mengembalikannya sebagai replay.
Apa yang dilihat Claude yang di-script
python3 client.py scenario, dengan UUID proposal dipersingkat. Baris expiry berasal dari server kedua yang dijalankan dengan TTL 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 mengubah app.py dan A mengubah README.md, tapi B tetap ditolak. Itulah tafsiran ketat dari "owner me-review ini terhadap 29a940e8". Perbaikannya murah: client membaca head yang baru lalu mengusulkan lagi, dan itulah B2. Baris git log menunjukkan provenance yang ditulis store. Author-nya adalah koneksi, committer-nya adalah orang yang meng-approve, dan message-nya membawa trailer Proposal: <id> dan Approved-by:.
Apa yang rusak tanpa compare-and-swap?
--mode naive mempertahankan semuanya kecuali transaksi. Mode ini mengecek tip di Python, membangun commit, lalu menjalankan git update-ref refs/heads/main <new> tanpa old value dan tanpa marker. Server sama, client sama, layout repository sama.
Uji race: dua proposal berbeda di atas base yang sama, di-approve pada saat yang persis sama dari dua thread (threading.Barrier), 100 percobaan per run, dua run. Setelah setiap percobaan, client bertanya ke git merge-base --is-ancestor apakah setiap commit yang dilaporkan executed benar-benar ada di main.
Uji crash: approve dengan kegagalan yang disuntikkan setelah langkah Git dan sebelum update database, lalu approve lagi. 50 percobaan per run, dua run. Client menghitung commit di main sebelum dan sesudahnya.
| Mode | Race: keduanya dilaporkan "executed" | Race: commit yang di-approve tidak ada di main | Crash, lalu retry | Commit tambahan setelah 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 |
Race di mode naive gagal di setiap percobaan, bukan sesekali. Jendela antara "tip sama dengan base" dan "tulis ref" panjangnya empat proses git, puluhan milidetik, dan dua thread yang dilepas oleh barrier selalu sama-sama mendarat di dalamnya. Kedua approval mengembalikan executed beserta commit ID. Owner melihat dua hasil hijau, padahal salah satu dari dua commit itu tidak ada di history branch. Tidak ada error sama sekali. Dengan compare-and-swap, setiap percobaan berakhir dengan tepat satu proposal executed dan satu penolakan stale yang menyebutkan head baru.
Retry setelah crash di mode naive tidak menghasilkan commit ganda. Pengecekan base menangkapnya, karena branch sudah bergerak. Tapi tertangkapnya sebagai stale. Commit milik proposal itu sendiri ada di main, sementara store sekarang melaporkan proposal itu sebagai ditolak. Kalau pengecekan base dibuang supaya masalah itu hilang, retry akan menulis commit itu untuk kedua kalinya. Replay marker-lah yang membuat retry bisa bilang "ini sudah terjadi, ini commit-nya".
Berapa biayanya?
Waktu wall-clock per request dari client lewat HTTP loopback (urllib, koneksi baru setiap kali), n = 200 per operasi per run, dua run. Rentangnya dari run 1 ke run 2.
| Operasi | CAS median | CAS p95 | naive median | naive p95 |
|---|---|---|---|---|
| propose (key baru) | 4.42–4.44 ms | 7.1–12.3 ms | 4.08–4.66 ms | 6.4–10.0 ms |
| propose (key sama, replay) | 1.42–1.50 ms | 3.3–4.5 ms | 1.27–1.49 ms | 2.4–4.1 ms |
| approve, dieksekusi | 115–119 ms | 199–257 ms | 92–105 ms | 130–193 ms |
| approve, ditolak sebagai stale | 28–31 ms | 52–70 ms | 14–17 ms | 24–34 ms |
| approve lagi, sudah dieksekusi | 1.46–1.60 ms | 3.4–4.7 ms | 1.38–1.54 ms | 2.6–4.2 ms |
Hampir semuanya adalah spawn proses. Approval CAS yang dieksekusi menjalankan delapan proses git (lookup marker, lookup tip, read-tree, hash-object, update-index, write-tree, commit-tree, update-ref --stdin), yang naive enam. Penolakan stale berarti dua lookup dibanding satu. Dengan kira-kira 14 ms per proses git di mesin yang sedang sibuk ini, jalur aman ini menambah sekitar dua spawn, sekitar 15–25 ms, pada request yang toh menunggu klik manusia. Server yang me-link libgit2 alih-alih memanggil shell akan memangkas semua kolom ini. Selisih di antara keduanya akan tetap dua lookup itu.
Replay adalah jalur termurah di kedua tabel, cukup satu read sqlite. Client yang melakukan retry justru lebih murah bagi server dibanding request baru.
Kalau kamu mau merakitnya sendiri
- Jangan beri agent tool write apa pun untuk repository, hanya
propose_*, dan cek di route decide bahwa pemanggilnya bukan token agent. Jangan mengandalkan agent tidak menemukan route itu. - Kunci apa yang dilihat reviewer, versi dan commit, di dalam proposal, lalu bandingkan saat approval. Tolak dengan menyertakan head terbaru di response, supaya client bisa membaca ulang dan mengusulkan lagi.
- Jadikan write ke Git sebagai satu transaksi ref: update branch dengan old value, ditambah marker create-only yang dinamai sesuai proposal. Cari marker itu sebelum membangun apa pun.
- Wajibkan idempotency key dengan panjang minimum, simpan bersama hash dari body, dan tolak key yang dipakai ulang dengan body berbeda.
- Tulis provenance ke dalam commit itu sendiri. Author-nya koneksi, committer-nya orang yang meng-approve, dan sebuah trailer membawa proposal ID.
git logakan tetap menyimpannya meskipun database kamu sudah hilang. - Berhenti di pull request. Merge dan deploy adalah urusan pemilik repository, di layarnya sendiri.
Sisi GitNest dari semua ini, yaitu clone, push, dan access token, dijelaskan di halaman GitNest osFoundry. Connector-nya berbicara MCP biasa, jadi tool proposal yang sama juga bisa dipakai dari Claude Code atau host MCP lainnya.


