जब कोई Claude chat osFoundry के MCP connector से किसी GitNest repository में files डालना चाहती है, तो वह यह tool इस्तेमाल करती है। यह live endpoint के public tools/list से लिया गया है, जिसे 2026-11-06 को curl से fetch किया गया था (catalogue पढ़ने के लिए 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"]}]} Verbs पर ध्यान दीजिए। Tool "prepares" करता है, और commit को "approves" owner करता है। Git को कुछ भी छूने से पहले ही call return हो जाती है, और connector पर कोई दूसरा tool भी नहीं है जो Git को छूता हो। idempotency_key optional नहीं, required है, और उसकी एक minimum length है, इसलिए client खाली या एक character वाली key भेजकर उसे idempotent नहीं कह सकता।
इस post का बाकी हिस्सा यह देखता है कि agent के लिए यह shape सही क्यों है, और उसे ईमानदार बनाए रखने के लिए server को क्या करना पड़ता है। फिर एक असली Git repository पर इसका सबसे छोटा version दोबारा बनाया गया है, और मापा गया है कि कुछ हिस्से हटा देने पर क्या टूटता है।
सात propose tools क्या pin करते हैं?
Connector पर GitNest के 13 tools हैं। छह सिर्फ़ पढ़ते हैं: read_gitnest_repository, search_gitnest_code, list_gitnest_work, read_gitnest_work, list_gitnest_saved_results और list_gitnest_checks। बाकी सात propose_ से शुरू होते हैं। उनके required fields बताते हैं कि model के call करते समय हर tool क्या चीज़ fix कर देता है:
| Tool | idempotency_key | expected_change_set_version | expected_composition_oid | openWorldHint |
|---|---|---|---|---|
propose_gitnest_work | required | नहीं | नहीं | false |
propose_gitnest_change_set | required | नहीं | नहीं | false |
propose_gitnest_server_composition | required | required | नहीं | false |
propose_gitnest_file_import | required | नहीं | नहीं | false |
propose_gitnest_check_rerun | required | required | required | false |
propose_gitnest_native_publication | required | required | required | true |
propose_gitnest_provider_publication | required | required | required | true |
जितना बड़ा असर, उतने ज़्यादा pins। एक task सिर्फ़ अपनी key pin करता है। Composing उस change-set version को pin करता है जो model ने पढ़ा था। Check को retry करना या pull request publish करना version के साथ-साथ exact composed commit भी pin करता है। दोनों publication tools पर openWorldHint: true लगा है, क्योंकि वे proposal store के बाहर कुछ बनाते हैं: एक pull request, और provider tool के मामले में GitHub या GitLab पर push की गई एक branch।
Owner के review card में ये pins text बनकर दिखते हैं: "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…"। Owner वही version और वही commit approve करता है, न कि वह जो click पहुंचने के समय current हो।
Agent को सीधे push token क्यों नहीं दे देते?
क्योंकि push उसी पल तय हो जाता है जब model उसे emit करता है, जबकि proposal तब तय होता है जब current rights वाला कोई इंसान उसे देखता है।
osFoundry का code यह line साफ़ तौर पर खींचता है। GitNest के write routes denyAgentScope से guarded हैं, और app के अंदर GitNest तक जाने वाला Maestro bridge सिर्फ़ reads की इजाज़त देता है। Connected tools किसी उधार के browser token से इसके आसपास रास्ता नहीं निकालते। हर proposal store होता है, connection owner को दिखाया जाता है, और approval के बाद osFoundry के अपने writer से execute होता है, connection के नाम पर attribute होकर। Claude conversation के पास कभी ऐसा credential नहीं होता जो repository में write कर सके, और वह owner का GitHub या GitLab token भी कभी नहीं देखती। Provider publication सिर्फ़ change set की dedicated branch push करता है, absent-ref lease के साथ, और unrelated refs को कभी mirror नहीं करता।
इस choice की कीमत है समय। "proposed" और "approved" के बीच का gap इंसानी समय है: कुछ मिनट, कभी-कभी एक दिन। उस बीच branch आगे बढ़ जाती है, कोई change set को दोबारा compose कर देता है, कोई check पूरा हो जाता है, owner का repository role चला जाता है, connection revoke हो जाता है। osFoundry का approval दोबारा check करता है connection, project membership, owner का current repository role, expiry, change-set actions के लिए exact version और commit, और publication के लिए risk policy और required checks। "A declined, expired, or stale proposal must not execute" उसके plan की एक line है।
दो failure modes बचते हैं, और rebuild इन्हीं पर है:
- एक ही base पर दो approvals। अलग-अलग देखें तो दोनों ठीक लगते हैं।
- Partial failure के बाद retry। Git में commit है, database को अभी पता नहीं, और owner फिर से click करता है।
दूसरे के लिए osFoundry का plan उस fix का ज़िक्र करता है जो Git service में ship हुआ: file imports immutable proposal ID को idempotency key के तौर पर pass करते हैं, और 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."
Rebuild
दो files, standard library और git CLI (Apple Git 2.39.5), एक Apple M1 Mac पर चलाई गईं जो उसी समय दूसरे jobs के लिए video भी render कर रहा था:
-
server.py, 225 lines: एक bare Git repository और एक sqlite file के ऊपर JSON-RPC endpoint। -
client.py, 203 lines: एक scripted "Claude" और एक scripted owner।
कोई Claude binary या Claude account शामिल नहीं था। Client उसी तरह की tools/call requests भेजता है जैसी एक connected chat भेजती। Rebuild के tools हैं read_repo, propose_file_import और read_proposal। ये नाम rebuild के अपने हैं, और इनमें से कोई भी Git में write नहीं करता। Owner एक अलग route /owner/decide पर फ़ैसला करता है, जो agent token को 403 agent_scope_denied से जवाब देता है। यही denyAgentScope वाला idea है, एक if में।
एक जानबूझकर किया गया फ़र्क: rebuild file imports पर भी base commit pin करता है (expected_base_oid, जिसका default proposal के समय का branch tip है)। osFoundry के propose_gitnest_file_import schema में ऐसा कोई field नहीं है। इस हिस्से को rebuild का design choice समझिए, osFoundry का description नहीं।
Proposal intake (connection, idempotency_key) पर idempotent है, body के hash के साथ, ताकि अलग body के साथ दोबारा इस्तेमाल हुई key error बने, चुपचाप replay नहीं:
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 सिर्फ़ plumbing से commit बनाता है (temporary index में read-tree, hash-object -w, update-index --cacheinfo, write-tree, commit-tree -p <base>)। इनमें से कुछ भी ref को move नहीं करता। फिर एक transaction branch को तभी move करता है जब वह अब भी reviewed base पर point कर रही हो, और proposal के नाम पर एक marker ref बनाता है:
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) Old value के साथ update compare-and-swap है। Ref पहले से मौजूद हो तो create fail होता है। Git या तो दोनों apply करता है या एक भी नहीं। कुछ भी बनाने से पहले approval refs/proposals/<id> ढूंढता है। अगर वह मौजूद है, तो पिछली कोशिश Git तक पहुंच चुकी थी, इसलिए वह उस commit को record करता है और उसे replay के तौर पर return करता है।
Scripted Claude को क्या दिखा
python3 client.py scenario, proposal UUIDs छोटे करके। Expiry वाली line 0.3 s TTL के साथ start किए गए दूसरे server से है:
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 ने app.py बदला और A ने README.md, फिर भी B refuse हुआ। यह "owner ने इसे 29a940e8 के against review किया" का strict मतलब है। Fix सस्ता है: client नया head पढ़ता है और फिर से propose करता है, और B2 यही है। git log वाली line वह provenance दिखाती है जो store लिखता है। Author connection है, committer वह इंसान है जिसने approve किया, और message में Proposal: <id> और Approved-by: trailers होते हैं।
Compare-and-swap के बिना क्या टूटता है?
--mode naive transaction को छोड़कर बाकी सब वैसा ही रखता है। यह Python में tip check करता है, commit बनाता है, फिर बिना old value और बिना marker के git update-ref refs/heads/main <new> चलाता है। वही server, वही client, वही repository layout।
Race test: एक ही base पर दो अलग proposals, दो threads से बिल्कुल एक ही पल में approve (threading.Barrier), हर run में 100 trials, दो runs। हर trial के बाद client git merge-base --is-ancestor से पूछता है कि executed बताया गया हर commit सच में main पर है या नहीं।
Crash test: Git step के बाद और database update से पहले एक injected failure के साथ approve, फिर दोबारा approve। हर run में 50 trials, दो runs। Client पहले और बाद में main पर commits गिनता है।
| Mode | Race: दोनों "executed" बताए गए | Race: approved commits जो main पर नहीं हैं | Crash, फिर retry | Retry के बाद extra commits |
|---|---|---|---|---|
| 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 |
Naive race हर trial में fail हुआ, कभी-कभार नहीं। "tip base के बराबर है" और "ref लिखो" के बीच की window चार git processes लंबी है, दसियों milliseconds, और barrier से छोड़े गए दोनों threads हर बार उसके अंदर पहुंच जाते हैं। दोनों approvals commit ID के साथ executed return करते हैं। Owner को दो green results दिखते हैं, और दोनों में से एक commit branch की history में है ही नहीं। कोई error नहीं आता। Compare-and-swap के साथ हर trial ठीक एक executed proposal और नए head का नाम बताने वाले एक stale refusal पर खत्म हुआ।
Naive crash retry ने double-commit नहीं किया। Base check ने उसे पकड़ लिया, क्योंकि branch आगे बढ़ चुकी थी। लेकिन पकड़ा stale के तौर पर। Proposal का अपना commit main पर है, और store अब उस proposal को refused बताता है। अगर इसे हटाने के लिए base check ही निकाल दें, तो retry commit को दूसरी बार लिख देगा। Replay marker ही retry को यह कहने देता है कि "यह पहले हो चुका है, यह रहा commit"।
इसकी कीमत क्या है?
Loopback HTTP पर client से हर request का wall-clock time (urllib, हर बार नया connection), हर run में हर operation के लिए n = 200, दो runs। Ranges run 1 से run 2 तक हैं।
| Operation | CAS median | CAS p95 | naive median | naive p95 |
|---|---|---|---|---|
| propose (नई key) | 4.42–4.44 ms | 7.1–12.3 ms | 4.08–4.66 ms | 6.4–10.0 ms |
| propose (वही key, replay) | 1.42–1.50 ms | 3.3–4.5 ms | 1.27–1.49 ms | 2.4–4.1 ms |
| approve, execute होता है | 115–119 ms | 199–257 ms | 92–105 ms | 130–193 ms |
| approve, stale के तौर पर refuse | 28–31 ms | 52–70 ms | 14–17 ms | 24–34 ms |
| फिर से approve, पहले ही executed | 1.46–1.60 ms | 3.4–4.7 ms | 1.38–1.54 ms | 2.6–4.2 ms |
लगभग सारा समय process spawns में जाता है। Execute होने वाला CAS approval आठ git processes चलाता है (marker lookup, tip lookup, read-tree, hash-object, update-index, write-tree, commit-tree, update-ref --stdin), naive वाला छह। Stale refusal में एक के मुकाबले दो lookups होते हैं। इस loaded machine पर हर git process लगभग 14 ms लेता है, तो safe path की कीमत करीब दो extra spawns है, लगभग 15–25 ms, उस request पर जो वैसे भी इंसान के click का इंतज़ार कर रही है। Shell out करने के बजाय libgit2 link करने वाला server इन सारे columns को कम कर देगा। उनके बीच का फ़र्क वही दो lookups रहेगा।
दोनों tables में replays सबसे सस्ता path हैं, बस एक sqlite read। Retry करने वाला client server के लिए नई request से भी सस्ता पड़ता है।
अगर आप इसे खुद wire करें
- Agent को repository के लिए कोई write tool मत दीजिए, सिर्फ़
propose_*, और decide route पर check कीजिए कि caller agent token नहीं है। इस भरोसे पर मत रहिए कि agent को route नहीं मिलेगा। - Reviewer ने जो देखा, version और commit, उसे proposal में pin कीजिए और approval पर compare कीजिए। Refuse करते समय response में current head दीजिए, ताकि client दोबारा पढ़कर फिर से propose कर सके।
- Git write को एक ref transaction बनाइए: old value के साथ branch update, साथ में proposal के नाम पर एक create-only marker। कुछ भी बनाने से पहले marker ढूंढिए।
- Minimum length वाली idempotency key required रखिए, उसे body के hash के साथ store कीजिए, और अलग body के साथ दोबारा इस्तेमाल हुई key को reject कीजिए।
- Provenance commit के अंदर ही लिखिए। Author connection है, committer approve करने वाला है, और एक trailer proposal ID रखता है। आपका database न रहे तब भी
git logमें यह मौजूद रहेगा। - Pull request पर रुक जाइए। Merge और deploy repository के owner का काम है, उसकी अपनी screen पर।
इसका GitNest वाला हिस्सा, यानी clone, push और access tokens, osFoundry के GitNest page पर बताया गया है। Connector सादा MCP बोलता है, इसलिए यही proposal tools Claude Code या किसी भी दूसरे MCP host से काम करते हैं।