Claude 채팅이 osFoundry를 거쳐 GitNest 저장소에 파일을 넣고 싶을 때 쓰는 tool이 이거다. osFoundry MCP 엔드포인트의 tools/list는 인증 없이도 찔러 볼 수 있다. 2026-11-06에 curl로 받아 온 응답에서 해당 항목만 뽑았다.
$ 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"]}]} 동사를 보면 답이 나온다. tool은 "prepare"만 하고, 커밋을 "approve"하는 건 오너다. 호출은 Git을 건드리기 전에 끝나고, 커넥터 어디에도 Git을 직접 건드리는 tool은 없다. idempotency_key는 선택이 아니라 필수고 최소 길이까지 있다. 빈 문자열이나 한 글자짜리 키로 멱등이라고 우길 수가 없다.
이 글에서는 에이전트의 쓰기를 왜 이런 모양으로 만드는지, 그게 말뿐이 되지 않으려면 서버가 뭘 해야 하는지 정리했다. 그다음 진짜 Git 저장소 위에 최소 버전을 다시 만들어 보고, 부품을 하나씩 빼면 뭐가 깨지는지 측정했다.
propose tool 7개는 각각 뭘 고정할까
커넥터에 있는 GitNest tool은 13개다. 그중 6개는 읽기 전용이다. read_gitnest_repository, search_gitnest_code, list_gitnest_work, read_gitnest_work, list_gitnest_saved_results, list_gitnest_checks. 나머지 7개가 propose_로 시작한다. 필수 필드를 나란히 놓으면 모델이 호출하는 순간 뭘 박아 두는지 보인다.
| Tool | 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 |
결과가 클수록 고정하는 것도 많아진다. 태스크는 키만 고정한다. 합성(composition)은 모델이 읽은 change set 버전을 고정한다. 체크 재실행과 PR 공개는 버전에 더해 합성된 커밋 자체까지 고정한다. publication 두 개만 openWorldHint: true인 건 제안 저장소 바깥에 결과를 남기기 때문이다. PR이 생기고, provider 쪽이면 GitHub나 GitLab에 브랜치가 push된다.
오너가 보는 승인 카드에는 이 값이 그대로 "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…"로 찍힌다. 오너가 승인하는 건 그 버전, 그 커밋이다. 클릭이 도착한 시점의 최신 상태가 아니다.
그냥 에이전트한테 push 토큰을 주면 안 될까
push는 모델이 출력하는 순간 결정된다. 제안은 지금 권한을 가진 사람이 들여다본 순간 결정된다. 차이는 이게 전부다.
osFoundry 코드는 이 선을 분명하게 그어 놨다. GitNest의 쓰기 라우트는 denyAgentScope로 막혀 있고, 앱 안의 Maestro에서 GitNest로 가는 브리지도 읽기만 허용한다. Connected tool이 브라우저 토큰을 빌려서 이걸 우회하지도 않는다. 제안은 저장되고, 커넥션 오너에게 보이고, 승인되면 osFoundry 자체의 쓰기 로직이 실행하면서 커넥션 이름으로 기록을 남긴다. Claude 대화는 저장소에 쓸 수 있는 인증 정보를 한 번도 쥐지 않고, 오너의 GitHub/GitLab 토큰도 못 본다. provider publication은 그 change set 전용 브랜치만, ref가 아직 없다는 조건(absent-ref lease)으로 push하고, 상관없는 ref를 미러링하지 않는다.
대신 시간이 든다. "제안됨"과 "승인됨" 사이는 사람의 시간이다. 몇 분일 때도 있고 하루일 때도 있다. 그 사이에 브랜치는 앞으로 가고, 누가 change set을 다시 합성하고, 체크가 끝나고, 오너가 저장소 권한을 잃거나 커넥션이 취소된다. osFoundry의 승인 처리는 커넥션, 프로젝트 소속, 오너의 현재 저장소 권한, 만료를 다시 확인한다. change set 쪽이면 버전과 커밋까지, publication이면 리스크 정책과 필수 체크까지 본다. 계획 문서에는 거절됐거나 만료됐거나 낡은 제안은 실행하면 안 된다는 문장이 있다.
그래도 실패 두 가지가 남는다. 이번에 다시 만들어 본 게 이 부분이다.
- 같은 베이스에 대한 승인 두 개. 각각만 보면 둘 다 멀쩡하다.
- 반쯤 실패한 뒤의 재시도. Git에는 커밋이 들어갔는데 DB는 아직 모른다. 그 상태에서 오너가 한 번 더 누른다.
두 번째에 대해서는 osFoundry 계획 문서에 Git 서비스 쪽 대응이 적혀 있다. 파일 import는 바뀌지 않는 제안 ID를 멱등 키로 Git 서비스에 넘기고, 서비스는 브랜치와 replay 마커를 compare-and-swap으로 원자적으로 갱신한다. 그래서 Git 커밋 뒤에 DB가 실패해도 커밋을 하나 더 만들지 않고 재시도할 수 있다.
직접 다시 만들어 봤다
파일은 두 개고, 표준 라이브러리와 git CLI(Apple Git 2.39.5)만 썼다. 돌린 건 Apple M1 Mac인데, 다른 작업의 영상 렌더링도 같이 돌고 있었다.
-
server.py(225줄): bare 저장소와 sqlite 파일 위에 띄운 JSON-RPC 엔드포인트 -
client.py(203줄): 스크립트로 흉내 낸 "Claude"와 오너
Claude 바이너리도 계정도 안 썼다. 클라이언트는 연결된 채팅이 보낼 법한 tools/call을 그대로 보낼 뿐이다. tool은 read_repo, propose_file_import, read_proposal 세 개다. 이름은 이 재구현에서 붙인 거고, 셋 다 Git에 쓰지 않는다. 오너는 별도 라우트 /owner/decide에서 결정하고, 이 라우트는 에이전트 토큰이 오면 403 agent_scope_denied를 돌려준다. denyAgentScope 아이디어를 if 하나로 옮긴 셈이다.
일부러 다르게 한 게 하나 있다. 재구현은 파일 import에도 베이스 커밋을 고정한다(expected_base_oid, 생략하면 제안 시점의 브랜치 끝). osFoundry의 propose_gitnest_file_import 스키마에는 이 필드가 없다. 이 부분은 재구현의 설계 선택이지 osFoundry 설명이 아니다.
제안 접수는 (커넥션, idempotency_key) 기준으로 멱등하게 했다. 본문 해시도 같이 들고 있어서, 같은 키에 내용만 다른 제안은 조용히 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 명령만으로 조립한다(임시 인덱스에 read-tree, hash-object -w, update-index --cacheinfo, write-tree, commit-tree -p <base>). 여기까지는 ref를 하나도 안 움직인다. 마지막에 트랜잭션 한 번으로, 브랜치가 아직 리뷰된 베이스를 가리킬 때만 옮기고 동시에 제안 ID 이름으로 마커 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 값을 준 update가 compare-and-swap이고, create는 ref가 이미 있으면 실패한다. git은 둘 다 적용하거나 둘 다 안 한다. 승인 로직은 뭔가 조립하기 전에 refs/proposals/<id>부터 찾는다. 있으면 앞선 시도가 Git까지 갔다는 뜻이니 그 커밋을 기록하고 replay로 돌려준다.
스크립트 Claude가 본 것
python3 client.py scenario 출력이다. 제안 UUID는 줄였다. 만료 줄만 TTL 0.3초로 띄운 별도 서버에서 나온 결과다.
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 A는 README.md, B는 app.py를 건드려서 충돌도 없는데 B는 거절됐다. "오너는 29a940e8 기준으로 리뷰했다"를 엄격하게 읽으면 이렇게 된다. 해결은 싸다. 클라이언트가 새 head를 다시 읽고 다시 제안하면 되고, 그게 B2다. git log 줄에는 이 저장소가 남기는 출처가 보인다. author는 커넥션, committer는 승인한 사람이고, 메시지에 Proposal: <id>와 Approved-by: 트레일러가 붙는다.
compare-and-swap을 빼면 뭐가 깨질까
--mode naive는 트랜잭션만 빼고 나머지는 다 남긴 모드다. head 확인은 Python에서 하고, 커밋을 조립한 다음 old 값도 마커도 없이 git update-ref refs/heads/main <new>를 날린다. 서버, 클라이언트, 저장소 구성은 똑같다.
경합 테스트: 같은 베이스에 대한 서로 다른 제안 두 개를 스레드 두 개에서 동시에 승인한다(threading.Barrier). 실행 한 번에 100회, 두 번 돌렸다. 매번 executed로 돌아온 커밋이 진짜 main 히스토리에 있는지 git merge-base --is-ancestor로 확인했다.
크래시 테스트: Git 처리 뒤, DB 갱신 전에 실패를 주입해서 승인하고, 한 번 더 승인한다. 실행 한 번에 50회, 두 번 돌렸다. 전후로 main 커밋 수를 셌다.
| 모드 | 경합: 둘 다 executed | 경합: 승인됐는데 main에 없는 커밋 | 크래시 후 재시도 | 재시도로 늘어난 커밋 |
|---|---|---|---|---|
| CAS + 마커, 1회차 | 0 / 100 | 0 | 50번 모두 executed (replay) | 0 |
| CAS + 마커, 2회차 | 0 / 100 | 0 | 50번 모두 executed (replay) | 0 |
| naive, 1회차 | 100 / 100 | 100 | 50번 모두 stale | 0 |
| naive, 2회차 | 100 / 100 | 100 | 50번 모두 stale | 0 |
naive 경합은 "가끔"이 아니라 매번 깨졌다. "head가 베이스와 같다"부터 "ref를 쓴다"까지 git 프로세스가 네 개 끼어 있어서 수십 ms짜리 틈이 생긴다. 배리어로 동시에 풀린 스레드 두 개는 매번 그 틈 안에 들어간다. 두 승인 모두 커밋 ID와 함께 executed를 돌려주니까 오너 눈에는 성공이 두 개다. 그런데 그중 하나는 브랜치 히스토리에 없다. 에러는 어디에도 안 뜬다. CAS 쪽은 모든 시도에서 executed 하나, 새 head를 담은 stale 거절 하나로 끝났다.
naive의 크래시 후 재시도는 이중 커밋까지는 안 갔다. 브랜치가 이미 앞으로 가 있어서 베이스 확인에서 걸렸기 때문이다. 문제는 걸린 모양이 stale이라는 거다. 그 제안의 커밋은 main에 들어가 있는데, 저장소는 그 제안을 거절됨으로 보고한다. 이게 보기 싫어서 베이스 확인을 빼면, 이번엔 재시도가 같은 내용을 한 번 더 커밋한다. "그건 이미 실행됐고 커밋은 이거야"라고 답할 수 있는 건 replay 마커가 있어서다. 삽질 포인트는 딱 여기다.
비용은 얼마나 될까
클라이언트에서 본 요청당 경과 시간이다. 루프백 HTTP(urllib, 매번 새 연결)로 실행 한 번에 연산별 200회, 두 번 돌렸다. 범위는 1회차~2회차 값이다.
| 연산 | CAS 중앙값 | CAS p95 | naive 중앙값 | naive p95 |
|---|---|---|---|---|
| 제안 (새 키) | 4.42–4.44 ms | 7.1–12.3 ms | 4.08–4.66 ms | 6.4–10.0 ms |
| 제안 (같은 키, replay) | 1.42–1.50 ms | 3.3–4.5 ms | 1.27–1.49 ms | 2.4–4.1 ms |
| 승인 후 실행 | 115–119 ms | 199–257 ms | 92–105 ms | 130–193 ms |
| 승인했지만 stale로 거절 | 28–31 ms | 52–70 ms | 14–17 ms | 24–34 ms |
| 이미 실행된 걸 다시 승인 | 1.46–1.60 ms | 3.4–4.7 ms | 1.38–1.54 ms | 2.6–4.2 ms |
거의 다 프로세스 띄우는 시간이다. CAS로 실행까지 가는 승인은 git을 8번 띄운다(마커 확인, head 확인, read-tree, hash-object, update-index, write-tree, commit-tree, update-ref --stdin). naive는 6번이다. stale 거절은 CAS가 2번, naive가 1번이다. 부하가 걸린 이 머신에서 git 한 번에 대략 14 ms니까, 안전한 경로의 추가 비용은 프로세스 두 번, 15–25 ms 정도다. 어차피 사람 클릭을 기다리는 요청이다. git을 셸로 부르지 않고 libgit2를 링크하면 모든 칸이 줄겠지만, 둘의 차이는 확인 두 번만큼 그대로 남는다.
두 표 모두에서 제일 싼 건 replay였다. sqlite를 한 번 읽으면 끝난다. 재시도하는 클라이언트가 새 요청보다 서버에 덜 부담을 준다.
직접 붙인다면
- 에이전트에게는 저장소 쓰기 tool을 주지 말고
propose_*만 준다. 결정 라우트에서는 호출자가 에이전트 토큰이 아닌지 확인한다. 에이전트가 라우트를 못 찾을 거라고 기대하지 않는다. - 리뷰어가 본 것(버전과 커밋)을 제안에 고정하고 승인 때 비교한다. 거절할 때는 현재 head를 같이 돌려줘서 클라이언트가 다시 읽고 다시 낼 수 있게 한다.
- Git 쓰기는 ref 트랜잭션 한 번으로 묶는다. old 값을 준 브랜치 업데이트와, 제안 이름의 create 전용 마커를 같이 넣는다. 뭔가 조립하기 전에 마커부터 찾는다.
- 멱등 키는 최소 길이가 있는 필수 값으로 하고, 본문 해시와 같이 저장한다. 같은 키에 내용이 다르면 거절한다.
- 출처는 커밋 자체에 쓴다. author는 커넥션, committer는 승인자, 트레일러에 제안 ID. DB가 날아가도
git log에는 남는다. - PR에서 멈춘다. 머지와 배포는 저장소 오너가 자기 화면에서 정한다.
GitNest 쪽 clone, push, 액세스 토큰 이야기는 osFoundry의 GitNest 페이지에 있다. 커넥터는 그냥 MCP라서, 같은 propose tool을 Claude Code에서든 다른 MCP 호스트에서든 쓸 수 있다.
