Das ist das Tool, das ein Claude-Chat benutzt, wenn er über den MCP-Connector von osFoundry Dateien in ein GitNest-Repository schreiben will. Es stammt aus dem öffentlichen tools/list des Live-Endpoints, am 2026-11-06 mit curl abgerufen (für den Katalog braucht man kein 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"]}]} Achte auf die Verben. Das Tool "bereitet vor", der Owner "genehmigt den Commit". Der Aufruf kehrt zurück, bevor irgendetwas Git anfasst, und es gibt auf dem Connector kein anderes Tool, das Git anfasst. Der idempotency_key ist Pflicht, nicht optional, und hat eine Mindestlänge. Ein Client kann also keinen leeren oder einstelligen Key schicken und das idempotent nennen.
Der Rest des Posts zeigt, warum diese Form für einen Agenten die richtige ist und was der Server tun muss, damit sie verlässlich bleibt. Danach wird die kleinste Version über einem echten Git-Repository nachgebaut und gemessen, was schiefgeht, wenn man Teile weglässt.
Was legen die sieben Propose-Tools fest?
GitNest hat 13 Tools auf dem Connector. Sechs lesen nur: read_gitnest_repository, search_gitnest_code, list_gitnest_work, read_gitnest_work, list_gitnest_saved_results und list_gitnest_checks. Die anderen sieben beginnen mit propose_. Ihre Pflichtfelder zeigen, was jedes davon in dem Moment festnagelt, in dem das Modell es aufruft:
| Tool | idempotency_key | expected_change_set_version | expected_composition_oid | openWorldHint |
|---|---|---|---|---|
propose_gitnest_work | Pflicht | nein | nein | false |
propose_gitnest_change_set | Pflicht | nein | nein | false |
propose_gitnest_server_composition | Pflicht | Pflicht | nein | false |
propose_gitnest_file_import | Pflicht | nein | nein | false |
propose_gitnest_check_rerun | Pflicht | Pflicht | Pflicht | false |
propose_gitnest_native_publication | Pflicht | Pflicht | Pflicht | true |
propose_gitnest_provider_publication | Pflicht | Pflicht | Pflicht | true |
Was festgelegt wird, wächst mit der Tragweite. Eine Aufgabe legt nur ihren Key fest. Das Komponieren legt die Change-Set-Version fest, die das Modell gelesen hat. Einen Check neu starten oder einen Pull Request veröffentlichen legt die Version und den exakten komponierten Commit fest. Beide Publication-Tools sind mit openWorldHint: true markiert, weil sie etwas außerhalb des Proposal-Stores erzeugen: einen Pull Request und beim Provider-Tool einen Branch, der zu GitHub oder GitLab gepusht wird.
In der Review-Karte des Owners werden diese Werte zu Text: "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…". Genehmigt wird genau diese Version und dieser Commit, nicht das, was gerade aktuell ist, wenn der Klick ankommt.
Warum gibt man dem Agenten nicht einfach ein Push-Token?
Weil ein Push in dem Moment entschieden ist, in dem das Modell ihn absetzt, und ein Vorschlag erst dann, wenn ihn ein Mensch mit aktuellen Rechten ansieht.
Der Code von osFoundry zieht diese Grenze ausdrücklich. Die Schreibrouten von GitNest sind durch denyAgentScope geschützt, und die In-App-Bridge von Maestro zu GitNest erlaubt nur Lesezugriffe. Die Connected Tools umgehen das nicht mit einem geliehenen Browser-Token. Jeder Vorschlag wird gespeichert, dem Owner der Verbindung angezeigt und nach der Genehmigung von osFoundrys eigenem Writer ausgeführt, der Verbindung zugeordnet. Eine Claude-Unterhaltung hält nie ein Credential, das ins Repository schreiben kann, und sieht nie das GitHub- oder GitLab-Token des Owners. Die Provider-Publication pusht nur den dedizierten Branch des Change Sets, mit einem Absent-Ref-Lease, und spiegelt nie fremde Refs.
Der Preis dafür ist Zeit. Die Lücke zwischen "vorgeschlagen" und "genehmigt" ist menschliche Zeit: Minuten, manchmal ein Tag. In diesem Fenster bewegt sich der Branch, jemand komponiert das Change Set neu, ein Check wird fertig, der Owner verliert seine Repository-Rolle, die Verbindung wird widerrufen. Die Genehmigung in osFoundry prüft erneut die Verbindung, die Projektmitgliedschaft, die aktuelle Repository-Rolle des Owners, den Ablauf und bei Change-Set-Aktionen die exakte Version und den Commit, bei Publications außerdem die Risk Policy und die Pflicht-Checks. "A declined, expired, or stale proposal must not execute" steht als Zeile in seinem Plan.
Zwei Fehlerfälle bleiben übrig, und um die geht es im Nachbau:
- Zwei Genehmigungen auf derselben Basis. Jede sieht für sich allein in Ordnung aus.
- Ein Retry nach einem teilweisen Fehler. Git hat den Commit, die Datenbank weiß es noch nicht, und der Owner klickt noch einmal.
Für den zweiten beschreibt der osFoundry-Plan den Fix, der im Git-Service ausgeliefert wurde: Datei-Importe übergeben die unveränderliche Proposal-ID als Idempotency-Key, und der 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."
Der Nachbau
Zwei Dateien, Standardbibliothek plus git-CLI (Apple Git 2.39.5), ausgeführt auf einem Mac mit Apple M1, der nebenbei Videos für andere Jobs gerendert hat:
-
server.py, 225 Zeilen: ein JSON-RPC-Endpoint über einem Bare-Git-Repository und einer sqlite-Datei. -
client.py, 203 Zeilen: ein geskripteter "Claude" und ein geskripteter Owner.
Weder ein Claude-Binary noch ein Claude-Account war beteiligt. Der Client schickt dieselbe Art von tools/call-Requests, die ein verbundener Chat schicken würde. Die Tools des Nachbaus sind read_repo, propose_file_import und read_proposal. Diese Namen gehören zum Nachbau, und keines davon schreibt in Git. Der Owner entscheidet auf einer separaten Route, /owner/decide, die auf ein Agent-Token mit 403 agent_scope_denied antwortet. Das ist die Idee von denyAgentScope in einem einzigen if.
Ein bewusster Unterschied: Der Nachbau legt auch bei Datei-Importen einen Basis-Commit fest (expected_base_oid, standardmäßig die Branch-Spitze zum Zeitpunkt des Vorschlags). Das Schema von propose_gitnest_file_import in osFoundry hat kein solches Feld. Betrachte diesen Teil als Designentscheidung des Nachbaus, nicht als Beschreibung von osFoundry.
Die Annahme von Vorschlägen ist idempotent auf (connection, idempotency_key), mit einem Hash des Bodys, damit ein wiederverwendeter Key mit anderem Body ein Fehler ist und kein stiller 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") Die Genehmigung baut den Commit nur mit Plumbing-Befehlen (read-tree in einen temporären Index, hash-object -w, update-index --cacheinfo, write-tree, commit-tree -p <base>). Nichts davon bewegt eine Ref. Danach bewegt eine einzige Transaktion den Branch nur, wenn er noch auf die geprüfte Basis zeigt, und legt eine Marker-Ref mit dem Namen des Vorschlags an:
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 mit einem alten Wert ist Compare-and-Swap. create schlägt fehl, wenn die Ref schon existiert. Git wendet beides an oder keins. Bevor irgendetwas gebaut wird, sucht die Genehmigung nach refs/proposals/<id>. Ist die Ref da, hat ein früherer Versuch Git schon erreicht. Dann wird dieser Commit gespeichert und als Replay zurückgegeben.
Was der geskriptete Claude gesehen hat
python3 client.py scenario, mit gekürzten Proposal-UUIDs. Die Ablauf-Zeile stammt von einem zweiten Server, der mit einer TTL von 0.3 s gestartet wurde:
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 hat app.py geändert und A README.md, und trotzdem wurde B abgelehnt. Das ist die strenge Lesart von "der Owner hat das gegen 29a940e8 geprüft". Der Fix ist billig: Der Client liest die neue Spitze und schlägt erneut vor, und genau das ist B2. Die git log-Zeile zeigt die Herkunft, die der Store schreibt. Author ist die Verbindung, Committer ist die Person, die genehmigt hat, und die Message trägt die Trailer Proposal: <id> und Approved-by:.
Was geht ohne Compare-and-Swap kaputt?
--mode naive behält alles außer der Transaktion. Es prüft die Spitze in Python, baut den Commit und führt dann git update-ref refs/heads/main <new> aus, ohne alten Wert und ohne Marker. Gleicher Server, gleicher Client, gleiches Repository-Layout.
Race-Test: zwei verschiedene Vorschläge auf derselben Basis, im selben Moment aus zwei Threads genehmigt (threading.Barrier), 100 Versuche pro Lauf, zwei Läufe. Nach jedem Versuch fragt der Client per git merge-base --is-ancestor, ob jeder als ausgeführt gemeldete Commit wirklich auf main liegt.
Crash-Test: genehmigen mit einem eingeschleusten Fehler nach dem Git-Schritt und vor dem Datenbank-Update, dann erneut genehmigen. 50 Versuche pro Lauf, zwei Läufe. Der Client zählt die Commits auf main vorher und nachher.
| Modus | Race: beide als "executed" gemeldet | Race: genehmigte Commits nicht auf main | Crash, dann Retry | Zusätzliche Commits nach Retry |
|---|---|---|---|---|
| CAS + Marker, Lauf 1 | 0 / 100 | 0 | 50 × executed (replayed) | 0 |
| CAS + Marker, Lauf 2 | 0 / 100 | 0 | 50 × executed (replayed) | 0 |
| naive, Lauf 1 | 100 / 100 | 100 | 50 × stale | 0 |
| naive, Lauf 2 | 100 / 100 | 100 | 50 × stale | 0 |
Das naive Race ist in jedem Versuch gescheitert, nicht nur gelegentlich. Das Fenster zwischen "Spitze gleich Basis" und "Ref schreiben" ist vier git-Prozesse lang, einige zehn Millisekunden, und zwei Threads, die von einer Barrier losgelassen werden, landen jedes Mal beide darin. Beide Genehmigungen liefern executed mit einer Commit-ID. Der Owner sieht zwei grüne Ergebnisse, und einer der beiden Commits ist nicht in der History des Branches. Nichts wirft einen Fehler. Mit Compare-and-Swap endete jeder Versuch mit genau einem ausgeführten Vorschlag und einer stale-Ablehnung, die den neuen Head nennt.
Der naive Crash-Retry hat nicht doppelt committet. Die Basisprüfung hat ihn abgefangen, weil sich der Branch bewegt hatte. Aber sie hat ihn als stale abgefangen. Der Commit des Vorschlags selbst liegt auf main, und der Store meldet diesen Vorschlag jetzt als abgelehnt. Wenn du die Basisprüfung entfernst, damit das verschwindet, schreibt der Retry den Commit ein zweites Mal. Erst der Replay-Marker erlaubt dem Retry zu sagen: "Das ist schon passiert, hier ist der Commit."
Was kostet das?
Wall-Clock-Zeit pro Request vom Client über Loopback-HTTP (urllib, jedes Mal eine neue Verbindung), n = 200 pro Operation und Lauf, zwei Läufe. Die Bereiche gehen von Lauf 1 bis Lauf 2.
| Operation | CAS Median | CAS p95 | naive Median | naive p95 |
|---|---|---|---|---|
| propose (neuer Key) | 4.42–4.44 ms | 7.1–12.3 ms | 4.08–4.66 ms | 6.4–10.0 ms |
| propose (gleicher Key, Replay) | 1.42–1.50 ms | 3.3–4.5 ms | 1.27–1.49 ms | 2.4–4.1 ms |
| approve, wird ausgeführt | 115–119 ms | 199–257 ms | 92–105 ms | 130–193 ms |
| approve, als stale abgelehnt | 28–31 ms | 52–70 ms | 14–17 ms | 24–34 ms |
| approve erneut, schon ausgeführt | 1.46–1.60 ms | 3.4–4.7 ms | 1.38–1.54 ms | 2.6–4.2 ms |
Fast alles davon sind Prozessstarts. Eine ausführende CAS-Genehmigung startet acht git-Prozesse (Marker-Lookup, Tip-Lookup, read-tree, hash-object, update-index, write-tree, commit-tree, update-ref --stdin), die naive sechs. Eine Stale-Ablehnung sind zwei Lookups gegenüber einem. Bei rund 14 ms pro git-Prozess auf dieser ausgelasteten Maschine kostet der sichere Pfad etwa zwei zusätzliche Prozessstarts, um die 15–25 ms, bei einem Request, der ohnehin auf einen menschlichen Klick wartet. Ein Server, der libgit2 linkt, statt Shell-Aufrufe zu machen, würde alle diese Spalten verkürzen. Der Unterschied zwischen ihnen bestünde weiterhin aus den zwei Lookups.
Replays sind in beiden Tabellen der billigste Pfad, ein einziger sqlite-Read. Ein Client, der einen Retry macht, kostet den Server weniger als ein neuer Request.
Wenn du das selbst baust
- Gib dem Agenten kein Schreib-Tool für das Repository, nur
propose_*, und prüfe auf der Entscheidungsroute, dass der Aufrufer kein Agent-Token ist. Verlass dich nicht darauf, dass der Agent die Route nicht findet. - Leg im Vorschlag fest, was der Reviewer gesehen hat, Version und Commit, und vergleiche es bei der Genehmigung. Lehne mit dem aktuellen Head in der Antwort ab, damit der Client neu lesen und erneut vorschlagen kann.
- Mach den Git-Write zu einer einzigen Ref-Transaktion: Branch-Update mit dem alten Wert plus ein Create-only-Marker mit dem Namen des Vorschlags. Such nach dem Marker, bevor du irgendetwas baust.
- Verlange einen Idempotency-Key mit Mindestlänge, speichere ihn mit einem Hash des Bodys und lehne einen wiederverwendeten Key mit anderem Body ab.
- Schreib die Herkunft in den Commit selbst. Author ist die Verbindung, Committer ist die genehmigende Person, und ein Trailer trägt die Proposal-ID.
git loghat das noch, wenn deine Datenbank längst weg ist. - Hör beim Pull Request auf. Merge und Deploy gehören dem, dem das Repository gehört, auf seinem eigenen Bildschirm.
Den GitNest-Teil davon, also Clone, Push und Access Tokens, beschreibt osFoundry auf der GitNest-Seite. Der Connector spricht einfaches MCP, deshalb funktionieren dieselben Propose-Tools auch aus Claude Code oder jedem anderen MCP-Host.
