# 02 · Contrato de herramientas

Referencia de lo que el Gateway envía a cada herramienta y de lo que espera de vuelta.

## Métodos del protocolo

| Método | Exigencia | Uso desde el Gateway |
|---|---|---|
| `server/discover` | **DEBE** | Primera llamada. Anuncia versiones soportadas, capacidades e identidad. Con el SDK oficial, este método **solo lo instala la entrada HTTP moderna**: si monta el transporte a mano, su servidor responde `-32601` y no pasa la certificación. El servidor de referencia usa el camino correcto y lo documenta en el código. |
| `tools/list` | **DEBE** | Catálogo con esquemas. Al registrar, al notificar cambio y al expirar la caché (15 min). |
| `tools/call` | **DEBE** | Ejecución de una herramienta. |
| `subscriptions/listen` | PUEDE | Aviso `toolsListChanged` cuando cambia el catálogo. Sin esto, el refresco es por caducidad. |

El Gateway solo acepta resultados con `resultType: "complete"`, que es lo que el SDK emite por
defecto. El patrón de varias vueltas (`input_required`) **no** se consume: toda herramienta se
resuelve en una sola llamada.

## Transporte

- Un único endpoint **HTTPS POST**, certificado válido.
- **Sin estado**: la revisión 2026-07-28 eliminó las sesiones de protocolo y el saludo de
  inicialización. Cada petición es autónoma.
- Cabeceras estándar `Mcp-Method` y `Mcp-Name` en cada POST.
- **Sin reanudación de flujo**: si la conexión se corta antes de recibir la respuesta, el Gateway
  reintenta como petición nueva, con un identificador de petición nuevo y **la misma clave de
  idempotencia**. De ahí la exigencia de la garantía 1. Esto es distinto de un tiempo de espera
  agotado: ahí el Gateway no reintenta, informa al agente y la conversación sigue sin la
  herramienta.
- `tools/list` **DEBERÍA** devolver las herramientas en orden estable. El propio estándar lo
  recomienda: un orden cambiante invalida la caché del modelo y degrada la latencia.

## Definición de una herramienta

```json
{
  "name": "abrir_caso_soporte",
  "title": "Abrir caso de soporte",
  "description": "Registra un caso de soporte para un cliente identificado y devuelve su número de radicado. Úsala solo cuando el cliente ya describió el problema. No la uses para consultar el estado de un caso existente.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["documento", "tipo", "descripcion"],
    "properties": {
      "documento":   { "type": "string", "maxLength": 20 },
      "tipo":        { "enum": ["falla", "instalacion", "cobro"] },
      "descripcion": { "type": "string", "maxLength": 400 },
      "prioridad":   { "type": ["string", "null"], "enum": ["alta", "normal", null] }
    }
  },
  "outputSchema": {
    "type": "object",
    "required": ["radicado", "estado"],
    "properties": {
      "radicado":  { "type": "string" },
      "estado":    { "enum": ["abierto", "en_cola"] },
      "sla_horas": { "type": "integer" }
    }
  },
  "annotations": {
    "readOnlyHint": false,
    "destructiveHint": false,
    "idempotentHint": true
  }
}
```

### Reglas del esquema

| Regla | Exigencia | Motivo |
|---|---|---|
| Todo parámetro **opcional acepta `null`** | **DEBE** | Sirven las dos formas: `"type": ["string","null"]` o `anyOf` con una rama `{"type":"null"}` (es lo que genera zod con `.nullable()`). Ver el aviso de abajo: es el requisito que más veces se pasa por alto y el que más rompe. |
| `required` con **solo lo imprescindible** | **DEBE** | Un `required` largo hace que el modelo invente valores para poder llamar a la herramienta. Deje fuera todo lo que su sistema pueda resolver por defecto. |
| `additionalProperties: false` | **DEBE** | Un esquema abierto invita al modelo a inventar campos. Es seguro: los metadatos del Gateway viajan en `_meta`, no en `arguments`. |
| Valores cerrados como `enum` | **DEBE** | Lo que no esté enumerado, el modelo lo adivina en lenguaje natural. |
| `maxLength` en todo texto libre | **DEBE** | Protege su backend y acota el coste del turno. |
| Sin identificadores internos como entrada obligatoria | **DEBE** | El modelo no puede construir un identificador suyo. Si hace falta, exponga primero una herramienta que lo resuelva. |
| Una operación por herramienta | **DEBE** | Un parámetro `accion` que multiplexa varias operaciones degrada la elección del modelo y no pasa la certificación. |
| Sin anidamiento profundo ni `oneOf`/`anyOf` en la raíz | **DEBERÍA** | Objetos planos de una capa se aciertan mucho más. |

> **Por qué los opcionales DEBEN aceptar `null`.** El proveedor del modelo valida el llamado contra
> su esquema **antes** de entregárnoslo. Si un parámetro opcional declara un tipo estricto y el
> modelo envía `null` (cosa que hace a menudo en vez de omitir el campo), la API responde **400** y
> **se cae la generación completa**: no es que falle esa herramienta, es que el agente se queda sin
> respuesta en medio de la conversación. Nos ocurrió en producción con nuestras propias
> herramientas; desde entonces todos los opcionales de nuestro catálogo admiten `null`. Los suyos
> también deben admitirlo.

### Clasificación de la herramienta: `_meta` en su definición

Cada herramienta **DEBE** declarar en el `_meta` de su propia definición a qué escenario del agente
pertenece y con qué verbo se le describe al modelo. No se manda en un archivo aparte ni se configura
en un panel: viaja con el catálogo, así que cuando usted añade una herramienta, ya viene clasificada.

| Clave en el `_meta` de la herramienta | Exigencia | Valor |
|---|---|---|
| `com.asixto/capability` | **DEBE** | Una de las **claves canónicas de escenario** de la [página 04](./04-capabilities.md). Determina en qué conversaciones se expone. |
| `com.asixto/verb` | **DEBE** | El verbo de negocio en infinitivo con el que el agente la nombra ante el cliente: `"agendar una cita"`, `"consultar el estado de un caso"`. |
| `com.asixto/capabilities` | PUEDE | Lista, si la misma herramienta sirve a varios escenarios (`["pqr","soporte"]`). Sustituye a la clave singular. |

```json
{
  "name": "abrir_caso_soporte",
  "description": "…",
  "inputSchema": { "…": "…" },
  "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": true },
  "_meta": {
    "com.asixto/capabilities": ["pqr", "soporte", "garantia"],
    "com.asixto/verb": "abrir un caso de soporte"
  }
}
```

> **Por qué es obligatorio y no un detalle.** El agente no expone todo su catálogo en cada
> conversación: filtra por escenario, y el texto que le dice al modelo qué puede hacer se construye
> con esos verbos. Una herramienta **sin `capability` no se expone nunca**, y una **sin `verb`** queda
> disponible pero el modelo no sabe que resuelve ese caso, así que casi no la usa. Los dos campos son
> tres palabras de trabajo y son la diferencia entre una herramienta que se usa y una que no.
>
> Una clave de escenario que no exista se rechaza en la certificación. No las invente: están todas en
> la [página 04](./04-capabilities.md).

### Nombres y longitudes

| Elemento | Límite | Motivo |
|---|---|---|
| Nombre de herramienta | **≤ 40 caracteres**, `^[a-z0-9_]+$`, único en su catálogo | El proveedor del modelo acepta `^[a-zA-Z0-9_-]{1,64}$`. El Gateway prefija con `ext__{empresa}__` para no colisionar con las herramientas nativas, y ese prefijo reserva 24 caracteres del presupuesto. Un nombre más largo no se puede exponer. |
| `description` de la herramienta | **≤ 700 caracteres** | El proveedor rechaza descripciones de más de 1.024. El margen es para el prefijo de contexto que añade el agente. |
| `description` de cada parámetro | ≤ 200 caracteres | Cada carácter se paga en cada turno de la conversación. |

- `snake_case`, verbo + entidad: `consultar_cliente`, `crear_cita`, `mover_etapa`.
- Estables. Renombrar equivale a publicar otra herramienta y obliga a recertificar.
- El prefijo lo pone el Gateway; no aparece en su servidor.

### Descripciones

La descripción es el texto que decide si el agente acierta al elegir la herramienta. No es
documentación interna.

- Una o dos frases: **qué hace** y **cuándo NO usarla**.
- Sin tono, sin marca, sin instrucciones dirigidas al modelo (`"responde amablemente…"`). Esa
  capa la pone el agente; si usted la duplica, entra en conflicto.
- Sin datos volátiles (precios, horarios, inventario): eso va en la respuesta, no en la
  descripción, que se cachea.

## Anotaciones y su efecto

| Anotación | Efecto en el Gateway |
|---|---|
| `readOnlyHint: true` | Se clasifica como nivel `read`. Se ejecuta sin confirmación. |
| `destructiveHint: true` | Se clasifica como nivel `admin`: queda fuera de los agentes con nivel `read` o `write`. |
| `idempotentHint: true` | Declara que repetir la llamada es seguro. **Hoy el runtime no reintenta por su cuenta**: ante timeout degrada y sigue la conversación. La anotación describe su herramienta; no active un comportamiento nuestro que aún no existe. |
| Ausentes | Se asume el caso más restrictivo: escritura no idempotente, nivel `admin`. |

Las anotaciones son **pistas**, no autoridad: el estándar las define como no verificables por el
consumidor. El Gateway las usa para clasificar el nivel; la decisión de exponer o no es del control
de acceso de Asixto y de la configuración del tenant. Declarar de solo lectura una herramienta que
escribe es un incumplimiento de contrato y bloquea la certificación.

> **No delegue la confirmación en el agente.** Que el agente pida confirmación antes de una acción
> sensible es una instrucción al modelo, y las instrucciones al modelo no son deterministas: en
> nuestras mediciones se cumplen alrededor del 92% de las veces.
>
> Para cualquier operación irreversible (cobrar, cancelar, borrar) la guarda que cuenta es la de
> **su** sistema: exija un campo de confirmación explícito en el esquema, valide estado y propiedad,
> y rechace lo que no cuadre. El porcentaje restante, cuando hay dinero o una cita de por medio, se
> convierte en un incidente.

## Metadatos que envía el Gateway

Van en el campo **`_meta` de los `params` de `tools/call`**, no en `arguments`. Esto es deliberado:
`_meta` es el punto de extensión que define el propio estándar, así que su `inputSchema` puede
seguir cerrado sin rechazar nada. El SDK se los entrega sin que usted declare nada.

| Clave en `_meta` | Tipo | Uso |
|---|---|---|
| `com.asixto/idempotencyKey` | string | Clave determinista por operación lógica, derivada de la conversación, la herramienta y la firma de los argumentos. **En toda escritura**: persistirla y no repetir el efecto. |
| `com.asixto/conversationId` | string | Ámbito de la conversación en curso. Dos conversaciones distintas nunca comparten clave de idempotencia. |
| `com.asixto/requestId` | string | Correlación de traza. Regístrelo: es la referencia para cualquier incidente. |
| `com.asixto/channel` | `"chat"` o `"voice"` | Canal. **Úselo**: en `voice` la respuesta se lee en voz alta y aplican las restricciones de la [página 07](./07-limits.md). |
| `com.asixto/companyId` | string | Empresa del tenant. Relevante si opera varias marcas o unidades de negocio detrás del mismo servidor. |
| `com.asixto/priceListId` | string o `null` | Lista de precios vigente para esa conversación. **Si su catálogo tiene varias, el precio debe salir de esta**, no del precio por defecto. |
| `com.asixto/currency` | string | Moneda del tenant en ISO 4217 (`COP`, `USD`…). Los importes que devuelva deben ser de esta moneda. |
| `com.asixto/timezone` | string | Zona horaria del tenant (`America/Bogota`). Toda fecha que devuelva debe llevar offset coherente con ella. |

```json
{
  "method": "tools/call",
  "params": {
    "name": "abrir_caso_soporte",
    "arguments": { "documento": "CC1032…", "tipo": "falla", "descripcion": "…" },
    "_meta": {
      "com.asixto/idempotencyKey": "9f2a7c…",
      "com.asixto/conversationId": "cnv_77b…",
      "com.asixto/requestId": "req_c41…",
      "com.asixto/channel": "voice",
      "com.asixto/companyId": "cmp_31a…",
      "com.asixto/priceListId": "pl_mayorista",
      "com.asixto/currency": "COP",
      "com.asixto/timezone": "America/Bogota"
    }
  }
}
```

> **Lo que el Gateway NO envía:** una identidad autenticada del usuario final. La conversación
> ocurre en WhatsApp, en un chat web o por teléfono, y ahí no hay sesión. Quien dice ser el cliente
> lo afirma en la conversación. Consecuencia directa para usted: la validación de propiedad
> (garantía 2 de la [página 05](./05-guarantees.md)) se hace contra el identificador que su propia
> herramienta de consulta ya resolvió en esa conversación, no contra un token de usuario.

## Forma de la respuesta

- `content`: texto breve para el modelo. Es lo que puede terminar leído en voz alta.
- `structuredContent`: los datos, conformes a su `outputSchema`.

Devuelva **datos ya resueltos**: el nombre en vez del identificador, el precio final en vez de
sus componentes, la fecha completa en vez de un código interno. Una respuesta pensada para una
tabla en pantalla no sirve en una llamada telefónica.

**Tamaño: 3.000 caracteres.** No es un consejo: el runtime sanea y **recorta a 3.000 caracteres**
todo resultado antes de inyectarlo al modelo, dejando una marca de truncado. No es un error, así que
su servidor no se entera: simplemente el agente responde con datos incompletos. Si su respuesta
natural es más grande, pagine ([página 07](./07-limits.md)).

**Una búsqueda sin resultados es un éxito, no un error.** Devuelva la lista vacía y el total en 0.
Si la reporta como error, el agente entra en la rama de fallo técnico y reintenta o cambia de paso
en vez de decirle al cliente, con naturalidad, que no encontró nada.

Prohibido en la respuesta: identificadores internos, datos de terceros, campos administrativos,
trazas, tokens. El resultado entra al contexto del modelo y queda en la traza de la conversación.

**Un fallo de negocio usa la misma respuesta, marcada como error.** No es un error de transporte: se
devuelve con `isError: true` y el objeto de error en `structuredContent`. La forma exacta y los seis
valores admitidos están en la [página 06](./06-errors.md).

```json
{
  "isError": true,
  "content": [{ "type": "text", "text": "{\"error\":{…}}" }],
  "structuredContent": { "error": { "error_type": "NOT_FOUND", "user_message": "No encontré una cita con esos datos." } }
}
```

El campo `resultType` que exige el estándar **lo pone el SDK**, no usted.

**Media (imágenes, PDF, audio): no en la v1.** El envío de archivos por el canal exige un
identificador de media resuelto contra el backend de Asixto; una URL o un adjunto que devuelva su
herramienta **no se le entrega al cliente final**, y en llamada se elimina del texto. Si su caso de
uso lo necesita, escálelo antes de construirlo.
