Claude 對話想透過 osFoundry 的 MCP 連接器把檔案放進 GitNest repository 時,用的就是下面這個工具。內容取自正式環境 endpoint 公開的 tools/list,2026-11-06 用 curl 抓取(讀取工具目錄不需要 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"]}]} 

注意動詞。工具負責「prepares」,由 owner 來「approves the commit」。呼叫在任何東西碰到 Git 之前就回傳了,而連接器上也沒有其他工具會碰到 Git。idempotency_key 是必填而不是選填,還有最小長度限制,所以 client 不能送一個空字串或單一字元的 key,然後聲稱自己是 idempotent。

本文接下來會說明為什麼這種設計對 agent 來說是對的,以及伺服器必須做哪些事才能讓它名副其實。接著在一個真實的 Git repository 上重建最小版本,並實際量測拿掉某些部分時會出什麼問題。

七個 propose 工具各自鎖定了什麼?

GitNest 在連接器上有 13 個工具。其中六個只做讀取:read_gitnest_repository、search_gitnest_code、list_gitnest_work、read_gitnest_work、list_gitnest_saved_results 和 list_gitnest_checks。另外七個都以 propose_ 開頭。從它們的必填欄位,可以看出每個工具在模型呼叫的當下把什麼固定下來:

工具 idempotency_key expected_change_set_version expected_composition_oid openWorldHint
propose_gitnest_work 必填 無 無 false
propose_gitnest_change_set 必填 無 無 false
propose_gitnest_server_composition 必填 必填 無 false
propose_gitnest_file_import 必填 無 無 false
propose_gitnest_check_rerun 必填 必填 必填 false
propose_gitnest_native_publication 必填 必填 必填 true
propose_gitnest_provider_publication 必填 必填 必填 true

影響越大,鎖定的東西越多。task 只鎖定自己的 key。compose 會鎖定模型讀到的 change set 版本。重跑 check 或發布 pull request,則同時鎖定版本和確切的 composed commit。兩個 publication 工具都標記為 openWorldHint: true,因為它們會在 proposal store 之外產生東西:一個 pull request,而 provider 工具還會把一個 branch push 到 GitHub 或 GitLab。

在 owner 的審查卡片上,這些鎖定值會變成文字:"Change set: … · expected version 3"、"Reviewed commit: 9c1e47a…"。owner 核准的是那個版本和那個 commit,而不是按下按鈕那一刻剛好最新的東西。

為什麼不直接給 agent 一個 push token?

因為 push 在模型送出的那一刻就決定了,而 proposal 是在一個擁有當下權限的人看過之後才決定。

osFoundry 的程式碼明確劃出這條界線。GitNest 的寫入 route 由 denyAgentScope 把關,而 app 內通往 GitNest 的 Maestro bridge 只允許讀取。Connected tools 也沒有借用瀏覽器 token 來繞過這一點。每個 proposal 都會被儲存、顯示給連線的 owner,核准後由 osFoundry 自己的 writer 執行,並記在該連線名下。Claude 對話從頭到尾都不會拿到能寫入 repository 的憑證,也看不到 owner 的 GitHub 或 GitLab token。provider publication 只會 push 該 change set 專用的 branch,搭配 absent-ref lease,絕不會鏡像無關的 ref。

這個選擇的代價是時間。從「proposed」到「approved」之間的空檔是人的時間:幾分鐘,有時一整天。這段期間 branch 會前進、有人重新 compose change set、某個 check 跑完、owner 失去 repository 角色、連線被撤銷。osFoundry 的核准流程會重新檢查連線、專案成員資格、owner 目前的 repository 角色、有效期限;change set 相關動作還會檢查確切的版本和 commit,publication 則再加上風險政策和必要的 check。"A declined, expired, or stale proposal must not execute" 就是它計畫中的一行。

剩下兩種失敗模式,也就是重建要處理的主題:

  1. 兩個核准針對同一個 base。各自看起來都沒問題。
  2. 部分失敗後的重試。Git 已經有了 commit,資料庫還不知道,owner 又按了一次。

針對第二種,osFoundry 的計畫描述了已在 Git service 上線的修正:file import 會把不可變的 proposal ID 當作 idempotency key 傳入,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."

重建版本

兩個檔案,只用標準函式庫加上 git CLI(Apple Git 2.39.5),在一台同時還在替其他工作 render 影片的 Apple M1 Mac 上執行:

  • server.py,225 行:架在 bare Git repository 和一個 sqlite 檔案上的 JSON-RPC endpoint。
  • client.py,203 行:一個寫成腳本的「Claude」和一個寫成腳本的 owner。

過程中沒有用到任何 Claude 執行檔或 Claude 帳號。client 送出的 tools/call 請求,和已連線的對話會送出的是同一類。重建版本的工具是 read_repo、propose_file_import 和 read_proposal。這些名稱是重建版本自己取的,而且沒有一個會寫入 Git。owner 透過另一個 route /owner/decide 做決定,這個 route 遇到 agent token 會回應 403 agent_scope_denied。這就是用一個 if 實現的 denyAgentScope 概念。

有一個刻意的差異:重建版本在 file import 時也會鎖定 base commit(expected_base_oid,預設是建立 proposal 當下的 branch tip)。osFoundry 的 propose_gitnest_file_import schema 沒有這個欄位。請把這部分當作重建版本的設計選擇,而不是 osFoundry 的描述。

proposal 的接收在 (connection, idempotency_key) 上是 idempotent 的,並保存 body 的 hash,所以同一個 key 搭配不同 body 會變成錯誤,而不是默默地 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") 

核准時只用 plumbing 指令建立 commit(read-tree 到暫存 index、hash-object -w、update-index --cacheinfo、write-tree、commit-tree -p <base>)。這些都不會移動任何 ref。接著用一個 transaction,只在 branch 仍指向審查過的 base 時才移動它,同時建立一個以 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) 

帶舊值的 update 就是 compare-and-swap。ref 已存在時 create 會失敗。Git 要嘛兩個都套用,要嘛都不套用。在建立任何東西之前,核准流程會先找 refs/proposals/<id>。如果它存在,代表先前的嘗試已經寫進 Git,於是記錄那個 commit 並當作 replay 回傳。

腳本化的 Claude 看到了什麼

python3 client.py scenario,proposal UUID 已縮短。過期那一行來自另一台以 0.3 s TTL 啟動的伺服器:

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 還是被拒絕了。這是對「owner 是基於 29a940e8 審查的」最嚴格的解讀。修正成本很低:client 讀取新的 head 再提案一次,B2 就是這樣來的。git log 那幾行顯示了 store 寫入的來源資訊。author 是連線,committer 是按下核准的人,訊息裡帶有 Proposal: <id> 和 Approved-by: trailer。

沒有 compare-and-swap 會壞掉什麼?

--mode naive 除了 transaction 以外全部保留。它在 Python 裡檢查 tip、建立 commit,然後不帶舊值也不建 marker,直接執行 git update-ref refs/heads/main <new>。同一個伺服器、同一個 client、同樣的 repository 配置。

競態測試:兩個不同的 proposal 基於同一個 base,從兩個 thread 在同一瞬間核准(threading.Barrier),每輪 100 次,跑兩輪。每次結束後,client 用 git merge-base --is-ancestor 確認每個回報為 executed 的 commit 是否真的在 main 上。

當機測試:在 Git 步驟之後、資料庫更新之前注入一次失敗再核准,然後再核准一次。每輪 50 次,跑兩輪。client 計算前後 main 上的 commit 數。

模式 競態:兩者都回報「executed」 競態:已核准但不在 main 上的 commit 當機後重試 重試後多出的 commit
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 的競態測試每一次都失敗,不是偶爾。從「tip 等於 base」到「寫入 ref」之間的空窗有四個 git process 那麼長,幾十毫秒,被 barrier 同時放行的兩個 thread 每次都會一起落在裡面。兩個核准都回傳 executed 和一個 commit ID。owner 看到兩個綠色結果,但其中一個 commit 根本不在 branch 的歷史裡。完全沒有錯誤。有了 compare-and-swap,每一次都剛好是一個 proposal executed,另一個收到 stale 拒絕,並附上新的 head。

naive 模式的當機重試沒有重複 commit。base 檢查擋住了它,因為 branch 已經前進了。但它是以 stale 的形式被擋下的。這個 proposal 自己的 commit 明明在 main 上,store 卻回報它被拒絕。如果為了消除這個問題而拿掉 base 檢查,重試就會把 commit 再寫一次。正是 replay marker,讓重試能說出「這件事已經做過了,這是那個 commit」。

代價是多少?

從 client 經 loopback HTTP 量測每個請求的實際耗時(urllib,每次都建立新連線),每種操作每輪 n = 200,跑兩輪。範圍為 run 1 到 run 2。

操作 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,執行 115–119 ms 199–257 ms 92–105 ms 130–193 ms
approve,以 stale 拒絕 28–31 ms 52–70 ms 14–17 ms 24–34 ms
再次 approve,已執行過 1.46–1.60 ms 3.4–4.7 ms 1.38–1.54 ms 2.6–4.2 ms

時間幾乎都花在啟動 process 上。一次執行成功的 CAS 核准會跑八個 git process(marker 查詢、tip 查詢、read-tree、hash-object、update-index、write-tree、commit-tree、update-ref --stdin),naive 則是六個。stale 拒絕是兩次查詢對一次。在這台負載偏高的機器上,每個 git process 大約 14 ms,所以安全路徑大約多兩次 spawn,約 15–25 ms,而這個請求本來就在等人按按鈕。改用 libgit2 而不是呼叫 shell 的伺服器,可以把這些欄位全部壓低。兩者之間的差距仍會是那兩次查詢。

replay 在兩張表裡都是最便宜的路徑,只要一次 sqlite 讀取。會重試的 client,對伺服器來說比新請求還便宜。

如果你要自己實作

  • 不要給 agent 任何能寫入 repository 的工具,只給 propose_*,並在 decide route 上檢查呼叫者不是 agent token。不要指望 agent 找不到那個 route。
  • 把審查者看到的東西(版本和 commit)鎖定在 proposal 裡,核准時拿來比對。拒絕時在 response 裡附上目前的 head,讓 client 可以重新讀取再提案。
  • 讓 Git 寫入成為單一的 ref transaction:帶舊值的 branch 更新,加上一個以 proposal 命名、只能建立的 marker。建立任何東西之前先找 marker。
  • 要求 idempotency key 並設定最小長度,連同 body 的 hash 一起儲存,同一個 key 搭配不同 body 時直接拒絕。
  • 把來源資訊寫進 commit 本身。author 是連線,committer 是核准者,再用一個 trailer 帶上 proposal ID。就算資料庫不見了,git log 裡還是會有。
  • 停在 pull request。merge 和部署屬於 repository 的擁有者,在他們自己的畫面上進行。

GitNest 本身的部分,包括 clone、push 和 access token,在 osFoundry 的 GitNest 頁面 有說明。這個連接器使用標準的 MCP,所以同樣的 proposal 工具也能在 Claude Code 或任何其他 MCP host 上使用。