Esta es la herramienta que usa un chat de Claude cuando quiere meter archivos en un repositorio de GitNest a través del conector MCP de osFoundry. Sale del tools/list público del endpoint en producción, obtenido con curl el 2026-11-06 (para el catálogo no hace falta 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"]}]} 

Fíjate en los verbos. La herramienta "prepara" y el propietario "aprueba el commit". La llamada devuelve antes de que nada toque Git, y en el conector no hay ninguna otra herramienta que sí lo toque. El idempotency_key es obligatorio, no opcional, y tiene una longitud mínima, así que un cliente no puede mandar una clave vacía o de un carácter y llamar a eso idempotente.

El resto del post explica por qué esa forma es la correcta para un agente y qué tiene que hacer el servidor para que siga siendo fiable. Después reconstruye la versión más pequeña sobre un repositorio Git real y mide qué falla cuando quitas piezas.

¿Qué fijan las siete herramientas de propuesta?

GitNest tiene 13 herramientas en el conector. Seis solo leen: read_gitnest_repository, search_gitnest_code, list_gitnest_work, read_gitnest_work, list_gitnest_saved_results y list_gitnest_checks. Las otras siete empiezan por propose_. Sus campos obligatorios muestran qué deja fijado cada una en el momento en que el modelo la llama:

Herramienta idempotency_key expected_change_set_version expected_composition_oid openWorldHint
propose_gitnest_work obligatorio no no false
propose_gitnest_change_set obligatorio no no false
propose_gitnest_server_composition obligatorio obligatorio no false
propose_gitnest_file_import obligatorio no no false
propose_gitnest_check_rerun obligatorio obligatorio obligatorio false
propose_gitnest_native_publication obligatorio obligatorio obligatorio true
propose_gitnest_provider_publication obligatorio obligatorio obligatorio true

Lo que se fija crece con las consecuencias. Una tarea solo fija su clave. Componer fija la versión del change set que leyó el modelo. Reintentar un check o publicar un pull request fija la versión y el commit compuesto exacto. Las dos herramientas de publicación llevan openWorldHint: true, porque producen algo fuera del almacén de propuestas: un pull request y, en el caso de la herramienta de proveedor, una rama enviada a GitHub o GitLab.

En la tarjeta de revisión del propietario esos valores fijados se convierten en texto: "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…". Lo que aprueba el propietario es esa versión y ese commit, no lo que sea actual cuando llegue el clic.

¿Por qué no darle al agente un token de push?

Porque un push se decide cuando el modelo lo emite, y una propuesta se decide cuando la mira una persona con permisos vigentes.

El código de osFoundry traza esa línea de forma explícita. Las rutas de escritura de GitNest están protegidas por denyAgentScope, y el puente de Maestro dentro de la app hacia GitNest solo permite lecturas. Las herramientas conectadas no se saltan eso con un token de navegador prestado. Cada propuesta se guarda, se le muestra al propietario de la conexión y la ejecuta el propio escritor de osFoundry tras la aprobación, atribuida a la conexión. Una conversación de Claude nunca tiene una credencial que pueda escribir en el repositorio, ni ve nunca el token de GitHub o GitLab del propietario. La publicación al proveedor solo hace push de la rama dedicada del change set, con un lease de ref ausente, y nunca replica refs ajenas.

El precio de esa decisión es tiempo. El hueco entre "propuesto" y "aprobado" es tiempo humano: minutos, a veces un día. En esa ventana la rama avanza, alguien recompone el change set, termina un check, el propietario pierde su rol en el repositorio, se revoca la conexión. La aprobación de osFoundry vuelve a comprobar la conexión, la pertenencia al proyecto, el rol actual del propietario en el repositorio, la caducidad y, en las acciones sobre change sets, la versión y el commit exactos, y en la publicación la política de riesgo y los checks obligatorios. "A declined, expired, or stale proposal must not execute" es una línea de su plan.

Quedan dos modos de fallo, y son el tema de la reconstrucción:

  1. Dos aprobaciones sobre la misma base. Cada una, por separado, parece correcta.
  2. Un reintento tras un fallo parcial. Git tiene el commit, la base de datos todavía no lo sabe y el propietario vuelve a hacer clic.

Para el segundo, el plan de osFoundry describe la solución que se incluyó en el servicio de Git: las importaciones de archivos pasan el ID inmutable de la propuesta como clave de idempotencia, y el servicio "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."

La reconstrucción

Dos archivos, librería estándar más el CLI de git (Apple Git 2.39.5), ejecutados en un Mac con Apple M1 que además estaba renderizando vídeo para otros trabajos:

  • server.py, 225 líneas: un endpoint JSON-RPC sobre un repositorio Git bare y un archivo sqlite.
  • client.py, 203 líneas: un "Claude" con guion y un propietario con guion.

No se usó ningún binario de Claude ni ninguna cuenta de Claude. El cliente envía el mismo tipo de peticiones tools/call que mandaría un chat conectado. Las herramientas de la reconstrucción son read_repo, propose_file_import y read_proposal. Esos nombres son propios de la reconstrucción, y ninguna de ellas escribe en Git. El propietario decide en otra ruta, /owner/decide, que responde a un token de agente con 403 agent_scope_denied. Es la idea de denyAgentScope en un solo if.

Una diferencia deliberada: la reconstrucción también fija un commit base en las importaciones de archivos (expected_base_oid, que por defecto es la punta de la rama en el momento de proponer). El esquema de propose_gitnest_file_import de osFoundry no tiene ese campo. Tómalo como una decisión de diseño de la reconstrucción, no como una descripción de osFoundry.

La recepción de propuestas es idempotente sobre (connection, idempotency_key), con un hash del cuerpo para que una clave reutilizada con otro cuerpo sea un error y no un replay silencioso:

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") 

La aprobación construye el commit solo con comandos plumbing (read-tree a un índice temporal, hash-object -w, update-index --cacheinfo, write-tree, commit-tree -p <base>). Nada de eso mueve una ref. Después, una sola transacción mueve la rama solo si sigue apuntando a la base revisada, y crea una ref marcadora con el nombre de la propuesta:

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 con un valor antiguo es compare-and-swap. create falla si la ref ya existe. Git aplica las dos o ninguna. Antes de construir nada, la aprobación busca refs/proposals/<id>. Si existe, un intento anterior ya llegó a Git, así que registra ese commit y lo devuelve como replay.

Qué vio el Claude con guion

python3 client.py scenario, con los UUID de las propuestas acortados. La línea de caducidad viene de un segundo servidor arrancado con un TTL de 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 tocaba app.py y A tocaba README.md, y aun así B fue rechazada. Es la lectura estricta de "el propietario revisó esto contra 29a940e8". La solución es barata: el cliente lee la nueva punta y vuelve a proponer, que es lo que es B2. La línea de git log muestra la procedencia que escribe el almacén. El autor es la conexión, el committer es la persona que aprobó, y el mensaje lleva los trailers Proposal: <id> y Approved-by:.

¿Qué se rompe sin compare-and-swap?

--mode naive lo mantiene todo excepto la transacción. Comprueba la punta en Python, construye el commit y luego ejecuta git update-ref refs/heads/main <new> sin valor antiguo y sin marcador. Mismo servidor, mismo cliente, misma estructura de repositorio.

Prueba de carrera: dos propuestas distintas sobre la misma base, aprobadas en el mismo instante desde dos hilos (threading.Barrier), 100 intentos por ejecución, dos ejecuciones. Tras cada intento, el cliente pregunta a git merge-base --is-ancestor si cada commit reportado como ejecutado está realmente en main.

Prueba de caída: aprobar con un fallo inyectado después del paso de Git y antes de actualizar la base de datos, y luego aprobar otra vez. 50 intentos por ejecución, dos ejecuciones. El cliente cuenta los commits en main antes y después.

Modo Carrera: ambas reportadas "executed" Carrera: commits aprobados que no están en main Caída y reintento Commits extra tras el reintento
CAS + marcador, ejecución 1 0 / 100 0 50 × executed (replayed) 0
CAS + marcador, ejecución 2 0 / 100 0 50 × executed (replayed) 0
naive, ejecución 1 100 / 100 100 50 × stale 0
naive, ejecución 2 100 / 100 100 50 × stale 0

La carrera en modo naive falló en todos los intentos, no de vez en cuando. La ventana entre "la punta es igual a la base" y "escribir la ref" dura cuatro procesos de git, decenas de milisegundos, y dos hilos liberados por una barrera caen dentro de ella todas las veces. Las dos aprobaciones devuelven executed con un ID de commit. El propietario ve dos resultados en verde, y uno de los dos commits no está en el historial de la rama. Nada da error. Con compare-and-swap, todos los intentos terminaron con exactamente una propuesta ejecutada y un rechazo stale que indicaba la nueva cabeza.

El reintento tras la caída en modo naive no duplicó el commit. Lo frenó la comprobación de la base, porque la rama se había movido. Pero lo frenó como stale. El commit de la propia propuesta está en main, y el almacén ahora informa de esa propuesta como rechazada. Si quitas la comprobación de la base para que eso desaparezca, el reintento escribe el commit por segunda vez. El marcador de replay es lo que permite que el reintento diga "esto ya pasó, aquí tienes el commit".

¿Cuánto cuesta?

Tiempo real por petición desde el cliente sobre HTTP en loopback (urllib, conexión nueva cada vez), n = 200 por operación y ejecución, dos ejecuciones. Los rangos van de la ejecución 1 a la ejecución 2.

Operación CAS mediana CAS p95 naive mediana naive p95
propose (clave nueva) 4.42–4.44 ms 7.1–12.3 ms 4.08–4.66 ms 6.4–10.0 ms
propose (misma clave, replay) 1.42–1.50 ms 3.3–4.5 ms 1.27–1.49 ms 2.4–4.1 ms
approve, se ejecuta 115–119 ms 199–257 ms 92–105 ms 130–193 ms
approve, rechazada como stale 28–31 ms 52–70 ms 14–17 ms 24–34 ms
approve otra vez, ya ejecutada 1.46–1.60 ms 3.4–4.7 ms 1.38–1.54 ms 2.6–4.2 ms

Casi todo es arrancar procesos. Una aprobación CAS que se ejecuta lanza ocho procesos de git (búsqueda del marcador, búsqueda de la punta, read-tree, hash-object, update-index, write-tree, commit-tree, update-ref --stdin), y la naive seis. Un rechazo por stale son dos búsquedas frente a una. A unos 14 ms por proceso de git en esta máquina cargada, el camino seguro cuesta unos dos procesos extra, alrededor de 15–25 ms, en una petición que de todas formas espera el clic de una persona. Un servidor que enlace libgit2 en lugar de llamar al shell recortaría todas estas columnas. La diferencia entre ellas seguiría siendo las dos búsquedas.

Los replays son el camino más barato en las dos tablas, una sola lectura de sqlite. Un cliente que reintenta le cuesta al servidor menos que una petición nueva.

Si lo montas tú

  • No le des al agente ninguna herramienta de escritura sobre el repositorio, solo propose_*, y comprueba en la ruta de decisión que quien llama no es un token de agente. No confíes en que el agente no encuentre la ruta.
  • Fija en la propuesta lo que vio el revisor, versión y commit, y compáralo al aprobar. Rechaza devolviendo la cabeza actual en la respuesta, para que el cliente pueda volver a leer y proponer de nuevo.
  • Haz que la escritura en Git sea una sola transacción de refs: actualización de la rama con el valor antiguo, más un marcador de solo creación con el nombre de la propuesta. Busca el marcador antes de construir nada.
  • Exige una clave de idempotencia con longitud mínima, guárdala con un hash del cuerpo y rechaza una clave reutilizada con otro cuerpo.
  • Escribe la procedencia en el propio commit. El autor es la conexión, el committer es quien aprueba y un trailer lleva el ID de la propuesta. git log lo seguirá teniendo cuando tu base de datos ya no exista.
  • Para en el pull request. El merge y el despliegue son cosa de quien sea dueño del repositorio, en su propia pantalla.

La parte de GitNest, clone, push y tokens de acceso, está descrita en la página de GitNest de osFoundry. El conector habla MCP estándar, así que las mismas herramientas de propuesta funcionan desde Claude Code o desde cualquier otro host MCP.