# Las llamadas duplicadas a herramientas MCP necesitan una identidad de ejecución

Una reconexión es un problema de transporte. Una segunda acción es un problema de ejecución. Los equipos sufren cuando tratan ambas cosas como si fueran iguales y dejan que el cliente reintente todas las solicitudes que no reciben una respuesta visible.

MCP facilita pasar esto por alto porque una llamada a una herramienta puede atravesar varios límites: un proceso de agente, un transporte MCP, una puerta de enlace de acciones y una API HTTP o un objetivo SSH. La conexión puede desaparecer después de que el objetivo haya aceptado el trabajo, pero antes de que el agente reciba el resultado. Si el sistema vuelve a enviar la llamada, el objetivo ve dos solicitudes válidas. No tiene ningún motivo para deducir que la segunda fue un accidente.

La solución no es aumentar el presupuesto de reintentos. Asigna una identidad de ejecución a cada acción solicitada, registra su ciclo de vida y decide los reintentos a partir de ese registro. Una huella de solicitud indica qué intentaba hacer el autor de la llamada. Un registro de actividad indica si el sistema ya la inició o completó. Necesitas ambas cosas.

## Una reconexión no autoriza otra ejecución

Un cliente que se reconecta después de que se rompa el flujo tiene pruebas de que falló la comunicación. No tiene pruebas de que fallara la llamada original a la herramienta.

La diferencia parece obvia hasta que alguien añade un middleware genérico de reintentos bajo un cliente MCP. El middleware ve un tiempo de espera agotado, un reinicio de conexión o una respuesta ausente. No sabe si el POST era una lectura, una escritura, un comando remoto o una operación irreversible. Vuelve a enviar los bytes porque eso es lo que suele hacer el código de reintentos HTTP.

Para una lectura como `GET /repos/acme/api/branches`, puede ser tolerable. Para `POST /payments`, `DELETE /projects/atlas` o un comando SSH que cambia un host de producción, puede crear un segundo efecto secundario. La capa de herramientas no puede arreglarlo después devolviendo un único resultado al modelo.

La especificación de transporte Streamable HTTP de MCP permite explícitamente que los clientes reanuden la entrega de eventos del servidor al cliente con `Last-Event-ID` cuando se interrumpe un flujo. Es un mecanismo de recuperación para los mensajes de un flujo. No convierte una segunda solicitud JSON-RPC `tools/call` en la misma ejecución. La documentación del SDK de TypeScript también separa los tokens de reanudación de la ruta de solicitud y permite middleware del cliente alrededor de `fetch`. Precisamente por eso los equipos deben expresar la regla de reintentos en el código, en lugar de suponer que el transporte los protegerá.

Aplica esta regla:

> Reanuda un flujo de respuestas cuando el protocolo lo admita. Vuelve a emitir una acción con efectos secundarios solo cuando la capa de acciones pueda identificarla como la misma ejecución.

Los casos difíciles no son los fallos limpios. Son aquellos en los que el servidor empieza a trabajar, la respuesta desaparece y el cliente no sabe si debe esperar, reanudar, consultar el estado o reintentar. El diseño debe hacer visible esa incertidumbre.

## Los IDs JSON-RPC identifican mensajes, no acciones duraderas

Un ID de solicitud JSON-RPC sirve para relacionar una solicitud con su respuesta. No basta para deduplicar una acción entre reconexiones, reinicios del proceso o ejecuciones separadas de un agente.

Considera este par de llamadas:

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

Pueden ser la misma solicitud enviada dos veces después de perderse la conexión. También pueden proceder de dos procesos de cliente distintos que empiezan a numerar en 1 o 41. Incluso dentro de un solo proceso, un error de implementación puede reutilizar los IDs. El valor no te dice casi nada si no lo vinculas a un autor autenticado y a una sesión de protocolo concreta.

Ahora considera dos llamadas con IDs distintos:

```json
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

```json
{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
```

Podrían ser un reintento de transporte cuyo cliente asignó un ID nuevo. O el agente podría haber solicitado deliberadamente un segundo despliegue después de recibir un resultado incierto. Los IDs de mensaje son una señal, no la decisión.

No cometas el error contrario y deduplica para siempre todas las llamadas a herramientas que coincidan. Desplegar dos veces la misma versión puede ser inofensivo o incluso intencionado. Crear dos veces el mismo ticket externo puede ser incorrecto. Rotar dos veces una credencial puede bloquear un servicio. La clase de acción determina cuánto tiempo sigue siendo significativa una identidad de ejecución.

Un modelo práctico mantiene separados tres identificadores:

- **ID de correlación del protocolo**: el ID JSON-RPC y, cuando corresponda, el contexto de sesión o flujo MCP.
- **ID de ejecución**: un identificador creado por el servidor para un intento aceptado de realizar una acción de herramienta.
- **Huella de intención**: un resumen estable del efecto solicitado, usado para encontrar una ejecución anterior cuando cambia la correlación del protocolo.

Cuando los separas, los registros dejan de fingir que responden a una pregunta que no pueden contestar.

## Una huella útil describe el efecto

Una huella de solicitud debe mantenerse igual cuando cambia la entrega y cambiar cuando cambia el efecto solicitado. No hagas un hash de los bytes JSON sin procesar y llames huella al resultado. El JSON sin procesar varía según el orden de las propiedades, los espacios, los valores predeterminados opcionales, los IDs de solicitud y los cambios de formato sin importancia.

Primero crea un registro de acción canónico. Para una acción HTTP, podría tener esta forma:

```json
{
  "actor": "signed-process:com.example.agent",
  "tool": "deploy_release",
  "channel": "http",
  "target": "deploy-api.internal.example/releases",
  "credential_ref": "deploy-service",
  "method": "POST",
  "arguments": {
    "service": "catalog",
    "version": "2026.07.22",
    "region": "us-east-1"
  },
  "intent_scope": "run:5f8097"
}
```

Normaliza el orden de los campos, omite los que no tengan significado semántico y normaliza las equivalencias conocidas antes de calcular el resumen. Si `region` tiene como valor predeterminado `us-east-1`, materialízalo siempre u omítelo siempre cuando el objetivo vaya a proporcionar ese valor. Mezclar ambas opciones crea falsos negativos.

El campo `actor` importa. Dos procesos de agente autorizados distintos que envían argumentos idénticos pueden representar acciones previstas separadas. `credential_ref` también importa. Una solicitud hecha con una identidad de servicio no equivale necesariamente a la misma ruta y el mismo cuerpo enviados con otra identidad. Para SSH, incluye la identidad del host, la cuenta, el comando, el directorio de trabajo si afecta al comportamiento y una representación normalizada del comando cuando puedas producirla de forma segura.

Mantén los secretos fuera del registro canónico. Nunca incluyas tokens portador, claves privadas ni encabezados de autorización sin procesar en la entrada de una huella. Si un argumento contiene un secreto, sustitúyelo por una referencia interna protegida o calcula la huella con una construcción con clave, como HMAC. Un hash simple y sin sal de un secreto con poca entropía convierte tu almacén de auditoría en un oráculo para probar conjeturas.

La recomendación habitual de «haz un hash de la solicitud» es popular porque es breve. Es incorrecta para controlar acciones. Un hash solo demuestra que unos bytes se pasaron a una función. No indica si esos bytes representan al mismo actor, el mismo efecto sobre el objetivo o la misma ventana de reintento.

## El registro de actividad necesita estados, no una sola línea

Un registro de actividad útil responde hasta dónde llegó la acción. Si solo registra éxito y fallo, una reconexión te dejará intentando adivinar justo cuando más necesitas una respuesta clara.

Registra al menos estas transiciones para cada ID de ejecución:

1. **Aceptada**: la puerta de enlace validó la solicitud y asignó un ID de ejecución.
2. **Autorizada**: la aprobación necesaria o la autorización de sesión permitió la acción.
3. **Enviada**: la puerta de enlace entregó la acción al cliente HTTP o al asistente SSH.
4. **Resultado observado**: llegó la respuesta del objetivo, el estado de salida o un fallo explícito de entrega.
5. **Resultado entregado**: el agente recibió el resultado de la herramienta, si el transporte puede demostrarlo.

El cuarto y el quinto estado deben mantenerse separados. Un objetivo puede devolver HTTP 201 mientras la conexión con el cliente MCP se rompe antes de que este vea la respuesta. Marcar esa ejecución como fallida porque falló la entrega del resultado es mentir. Marcarla como completada da al código de recuperación algo útil: puede devolver o reconstruir el resultado conocido sin enviar otra solicitud.

Este es el formato de registro que quiero ver durante un incidente:

```json
{
  "execution_id": "act_01J4K8J7DX7V",
  "fingerprint": "hmac-sha256:4a1e...d90c",
  "tool": "deploy_release",
  "actor": "signed-process:com.example.agent",
  "target": "deploy-api.internal.example/releases",
  "state": "completed_result_not_delivered",
  "accepted_at": "2026-07-22T14:03:18Z",
  "dispatched_at": "2026-07-22T14:03:19Z",
  "completed_at": "2026-07-22T14:03:25Z",
  "target_status": 201,
  "result_reference": "result_01J4K8JFM2"
}
```

El registro no tiene que exponer la respuesta completa del objetivo a todos los operadores. Necesita suficientes detalles protegidos para que la puerta de enlace tome una decisión de recuperación y suficientes detalles legibles para que una persona entienda lo ocurrido.

El registro de actividad de Sallyport guarda llamadas individuales, mientras que su registro de sesiones guarda las ejecuciones de los agentes. Esa separación resulta útil en esta investigación: la ejecución indica qué proceso de agente existía y el registro de llamadas indica si una acción concreta hacia el mundo exterior cruzó el límite de envío. Su cadena de auditoría también puede verificarse sin conexión con `sp audit verify`, lo que ayuda a demostrar que el registro no se reescribió en silencio después de un incidente.

## Trata los resultados desconocidos como un resultado separado

La mayoría de las acciones duplicadas empiezan con un sistema que solo tiene dos resultados: éxito y fallo. Las acciones en red necesitan un tercero: desconocido.

Desconocido no significa que el sistema no hiciera nada. Significa que el sistema no puede demostrar si el objetivo aceptó la acción. Un tiempo de espera agotado antes de que salgan bytes del proceso suele poder reintentarse sin peligro. Un tiempo de espera agotado después de que el cuerpo de una solicitud HTTP se haya entregado al sistema operativo no es el mismo evento. Una conexión SSH rota después de que el shell remoto haya iniciado un comando es aún peor, porque el comando remoto puede continuar después de que termine el proceso local.

Clasifica cada acción de herramienta antes de decidir cómo recuperarla:

| Tipo de acción | Ejemplo | Valor predeterminado después de un resultado desconocido |
|---|---|---|
| Solo lectura | Consultar el estado de una compilación | Reintentar con límites normales |
| Escritura idempotente | Establecer un recurso con nombre en un estado declarado | Reintentar usando la misma identidad de idempotencia |
| Escritura condicional | Actualizar solo si coincide la versión | Consultar el estado y reintentar solo si la condición sigue cumpliéndose |
| Acción irreversible | Enviar un pago, revocar acceso, rotar una credencial | Detenerse y solicitar revisión explícita |
| Comando remoto | Ejecutar una migración mediante SSH | Consultar una marca duradera o detenerse para revisión |

El verbo HTTP de una API no resuelve esta tabla. `PUT` suele describirse como idempotente, pero un endpoint mal diseñado puede enviar una notificación, activar una compilación o añadir un evento de auditoría cada vez que recibe la solicitud. `POST` puede repetirse sin peligro cuando la API respeta una clave de idempotencia. Revisa el contrato real del objetivo.

Para comandos remotos de larga duración, añade una marca duradera antes de ejecutar el trabajo. Un comando de migración puede crear un registro con un ID de ejecución, actualizarlo cuando empiece el trabajo y marcarlo como completado solo después de la validación. Al reconectarte, consulta esa marca antes de volver a enviar el comando. Sin una marca, «probablemente no se ejecutó» no es una estrategia de recuperación.

## Relaciona los reintentos dentro de un ámbito de intención limitado

Una huella por sí sola relacionará demasiado trabajo legítimo. Limítala al periodo y al contexto en los que un reintento tenga sentido.

El ámbito más sencillo es una ejecución del agente. Si el mismo proceso firmado envía la misma acción mientras el primer resultado sigue sin resolverse, trata la segunda solicitud como posible reintento. Si otro proceso la envía horas después, considérala una intención nueva, salvo que la propia acción proporcione una clave de idempotencia duradera.

Una buena regla de coincidencia se parece a esta:

```text
if prior.fingerprint == incoming.fingerprint
  and prior.actor == incoming.actor
  and prior.intent_scope == incoming.intent_scope
  and prior.state in {accepted, authorized, dispatched, completed_result_not_delivered}:
    recover_or_attach_to(prior.execution_id)
else:
    create_new_execution()
```

`recover_or_attach_to` no debe devolver éxito a ciegas. Su comportamiento depende del estado anterior.

Si la ejecución anterior fue aceptada pero aún no se envió, la puerta de enlace puede continuarla. Si se envió y el resultado es desconocido, la puerta de enlace debe consultar el endpoint de estado, el mecanismo de idempotencia o la marca duradera del objetivo. Si se completó pero falló la entrega del resultado, debe devolver la referencia al resultado almacenado. Si la autorización la rechazó, debe devolver ese rechazo en lugar de crear una nueva ruta de aprobación a partir del mismo reintento ambiguo.

El ámbito debe corresponder a la acción. Una ventana de cinco minutos puede ser razonable para una solicitud API que agota el tiempo de espera. No basta para un despliegue de software que dura una hora. Una rotación de credenciales puede requerir una huella duradera hasta que puedas verificar qué credencial está activa. No uses un TTL global solo porque sea fácil de configurar. Aplica reglas de retención y recuperación específicas para cada acción.

## La aprobación es una prueba, no un mecanismo de idempotencia

Una aprobación humana puede demostrar que un proceso tenía permiso para intentar una acción. No puede demostrar si un intento anterior ya ocurrió.

Esto importa en sistemas que solicitan aprobación para cada llamada sensible. Supón que un agente pide rotar una credencial de producción. Una persona lo aprueba. La puerta de enlace envía la solicitud y el cliente se desconecta. El agente se reconecta y genera la misma llamada a la herramienta. Volver a pedir aprobación presenta una elección engañosa. El operador ve una solicitud conocida y puede aprobarla, pero la pregunta que necesita responder es si la primera rotación terminó.

La aprobación por llamada sigue teniendo su lugar. Controla la autorización en el momento de uso. Mantenla separada de la gestión de duplicados:

- La autorización decide si el autor de la llamada actual puede iniciar una ejecución.
- La huella decide si una solicitud entrante corresponde a una ejecución existente.
- Los registros de actividad deciden si esa ejecución existente puede reanudarse, recuperarse o debe revisarse.

Cuando un reintento corresponde a una ejecución pendiente, muestra el registro de actividad original en lugar de presentar una aprobación nueva como si nada hubiera ocurrido. El revisor debe ver el objetivo, la primera hora de envío, el resultado conocido y el motivo por el que la puerta de enlace no volvió a enviar la acción.

Sallyport usa una secuencia fija de decisiones: un almacén bloqueado rechaza las acciones, un proceso de agente nuevo recibe autorización por sesión de forma predeterminada y las credenciales seleccionadas pueden exigir aprobación en cada uso. Esos controles responden a quién puede actuar. El registro de ejecución todavía debe responder si la acción ya cruzó el límite.

## Las claves de idempotencia HTTP solo resuelven una parte del problema

Si una API ascendente acepta claves de idempotencia, úsalas. Envía un valor estable durante toda la vida de una ejecución, conserva la respuesta del objetivo y reutiliza ese valor solo al recuperar la misma ejecución.

Por ejemplo, la puerta de enlace puede crear un ID de ejecución antes del envío y asociarlo con el encabezado que espera la API:

```http
POST /v1/releases HTTP/1.1
Host: deploy-api.internal.example
Idempotency-Key: act_01J4K8J7DX7V
Content-Type: application/json

{"service":"catalog","version":"2026.07.22","region":"us-east-1"}
```

La API debe definir qué hace cuando se repite ese encabezado. Lo mejor es devolver el resultado original para la misma solicitud semántica y rechazar una solicitud diferente que intente reutilizar el mismo valor. Si acepta en silencio un cuerpo cambiado con la misma clave, la puerta de enlace no puede deducir nada de forma segura a partir de una repetición.

No uses la propia huella como clave de idempotencia externa si puede persistir entre acciones intencionadas. Un ID de ejecución es único para un intento aceptado. La huella localiza un intento potencialmente relacionado. Cumplen funciones distintas.

La idempotencia HTTP tampoco sirve por sí sola para SSH. Necesitas un protocolo remoto. Un patrón seguro consiste en pasar un ID de ejecución generado a un script que escriba un registro de estado duradero en el host o en un almacén compartido y que se niegue a iniciar dos veces la misma operación. Si no puedes modificar el comando ni consultar una marca externa, clasifica el comando como irreversible y exige una revisión después de una desconexión incierta.

## Investiga la secuencia, no el recuento final

Dos filas de actividad con argumentos coincidentes no demuestran que haya un duplicado. Empieza por la secuencia de eventos y sigue la primera llamada hasta su límite de envío.

Una investigación real debe responder estas preguntas en orden:

1. ¿Uno o dos procesos distintos de agente enviaron las llamadas?
2. ¿La primera llamada recibió autorización y entró en la fase de envío?
3. ¿La puerta de enlace recibió una respuesta o un estado de salida del objetivo?
4. ¿Falló la entrega del resultado después de que el objetivo terminara?
5. ¿La segunda llamada reutilizó el ID de ejecución original, llevaba una clave de idempotencia o creó un intento nuevo?

Este orden evita una conclusión errónea habitual: «Los registros muestran dos llamadas, así que el agente actuó dos veces». Puedes descubrir que la puerta de enlace registró una ejecución completada y un reintento del cliente que se vinculó a ella. O puedes encontrar dos procesos autorizados distintos, cada uno con un contexto de planificación diferente, que emitieron la acción. Cada caso necesita una solución distinta.

Mantén el almacén de actividad como un registro de solo anexado o hazlo resistente a manipulaciones de otra forma. Las investigaciones de duplicados suelen ocurrir después de un incidente costoso, cuando alguien quiere una historia más limpia de la que el sistema puede respaldar. Un registro encadenado mediante hashes no vuelve correcta la decisión original, pero dificulta manipular la reconstrucción posterior.

Tampoco ocultes la ambigüedad al agente. Devuelve un resultado que indique que la ejecución anterior está pendiente de verificación o que se completó pero la entrega del resultado se interrumpió. Un modelo que ve un fallo inventado intentará de nuevo. Un modelo que ve un estado incierto claro puede consultar el estado, pedir una revisión o elegir una ruta más segura.

## Haz que el comportamiento ante repeticiones forme parte del contrato de cada herramienta

Cada herramienta con efectos secundarios necesita una respuesta explícita a una pregunta: ¿qué ocurre cuando el autor de la llamada pierde la respuesta después del envío?

Escribe la respuesta junto a la definición de la herramienta. Indica si la acción es de solo lectura, repetible con una identidad de idempotencia, recuperable mediante una consulta de estado o bloqueada después de un resultado desconocido. Especifica qué debe incluir su huella y durante cuánto tiempo una ejecución sin terminar puede seguir aceptando solicitudes vinculadas. Si nadie puede escribirlo, la herramienta no está lista para un uso autónomo.

El trabajo de ingeniería suele ser modesto comparado con la limpieza posterior a un despliegue duplicado, una cuenta duplicada, un pago duplicado o una segunda rotación de credenciales. Añade el ID de ejecución antes de llamar al objetivo. Conserva las transiciones de estado antes y después del envío. Guarda una referencia al resultado. Después haz que el código de reconexión consulte ese registro antes de volver a tocar el mundo exterior.

Ese es el estándar que debes mantener: un transporte interrumpido puede cortar una conversación, pero no debe convertir silenciosamente la incertidumbre en una segunda acción.
