biller-mcp-server
v0.1.1
Published
MCP server local, read-only, para la API REST de Biller (facturación electrónica de Uruguay). Solo llamadas HTTP GET.
Maintainers
Readme
Biller MCP server
MCP server local para la API REST de Biller (facturación electrónica de Uruguay). Permite que asistentes como Claude Desktop / Claude Code consulten y operen Biller de forma conversacional.
Tiene dos modos operativos (controlados por BILLER_CAPABILITY_MODE):
read_only(default): solo las 27 tools de lectura se registran en el servidor MCP. SoloGET. Modo seguro para producción y uso sin riesgo.write_enabled: se agregan las 7 tools de escritura (POST) — protegidas por dry-run + confirmación + gate de ambiente + idempotencia + audit. La ejecución real delPOSTademás requiereBILLER_WRITE_ENABLED=true.Stack: TypeScript + Node.js,
@modelcontextprotocol/sdk, Zod, Vitest. Transporte: stdio.Fuente de verdad de endpoints/campos: la documentación oficial de la API de Biller (OpenAPI). La emisión de CFE vive en
POST /v3/comprobantes/emitir; el resto de las operaciones sigue env2.
Advertencia fiscal. Emitir o anular un CFE genera un documento real ante DGI. No es irreversible —una venta mal emitida se anula con una Nota de Crédito, y si esa anulación fue el error, una Nota de Débito le devuelve validez al original— pero cada corrección es otro comprobante, con su numeración y su envío a DGI: se arregla, no se deshace. Por eso la escritura está apagada por defecto (
BILLER_WRITE_ENABLEDno seteado) y, aun encendida, exige confirmación explícita por operación. Probá siempre primero enhttps://test.biller.uy.
Instalación (usuarios)
Claude Desktop / Claude Code — un solo comando, contesta tres preguntas (ambiente, token, dónde registrarlo) y escribe la configuración por vos:
npx biller-mcp-server initQueda en solo lectura contra el ambiente que elijas. La escritura se habilita a mano, a conciencia (ver Modos operativos).
Como plugin de Claude Code (trae además el skill de vocabulario
biller-consultas): dentro de claude, con BILLER_API_TOKEN en tu entorno:
/plugin marketplace add MateoFabregat/MCP_Biller
/plugin install biller@billerChatGPT / Claude web y móvil todavía no: requieren un servidor remoto con OAuth (en el roadmap). Este paquete corre local por stdio.
Qué hace
Lectura
- Lista comprobantes emitidos (
GET /v2/comprobantes/obtener), con todos los campos reales que devuelve Biller (ver Campos del comprobante). - Obtiene un comprobante por
id,numero_internoo ternatipo+serie+numero(conidincluye el detalle deitems[]tipado). - Descarga el PDF de un comprobante (
GET /v2/comprobantes/pdf). - Lista comprobantes recibidos DGI (
GET /v2/comprobantes/recibidos/obtener). - Resumen de facturación por período, con cortes por sucursal, día, mes, tipo, moneda o cliente (ver Reportes).
- Vencimientos y aging: qué vence esta semana y qué ya venció, por tramos y por cliente (ver Vencimientos).
- Cuenta corriente: deuda neta por cliente y por factura — lo facturado a crédito menos lo cobrado, con recibos totales y parciales (ver Cuenta corriente).
- Alertas operativas: comprobantes rechazados por DGI y CAEs por agotarse o vencer (ver Alertas).
- Datos DGI por RUT (nombre, datos de entidad, actividad, certificado único).
- Health check (no llama a Biller, nunca revela el token).
Escritura (con barreras, ver más abajo)
- Emitir comprobante (
POST /v3/comprobantes/emitir). - Anular comprobante (
POST /v2/comprobantes/anular). - Crear cliente (
POST /v2/clientes/crear). - Cargar producto/servicio (
POST /v2/productos/cargar). - Crear recibo (
POST /v2/recibos/crear). - Cancelar recibo (
POST /v2/recibos/cancelar). - Registrar pago (
POST /v2/pagos/crear).
Límites
- Las tools de escritura no ejecutan
POSTsinBILLER_WRITE_ENABLED=truey confirmación explícita por operación. El preview/dry-run está disponible aunque la escritura real esté apagada. - En producción, la escritura requiere doble habilitación: variable de entorno y
argumento
allow_production. - El resumen de facturación no consolida monedas. Los importes se devuelven
separados por moneda; el campo
tasa_cambiose expone en los comprobantes cuando Biller lo devuelve. - No hay tool de listado de clientes porque el OpenAPI público no documenta un endpoint GET para esa operación.
- La cobranza sí se lee, pero la imputación factura-por-factura puede ser
estimada. Los recibos son CFE y vuelven en
GET /v2/comprobantes/obtenerconindicador_cobranza_propia = 1, así que el saldo por cliente es exacto. Lo que el listado no trae es a qué factura se imputó cada cobro:biller_cuenta_corrienteconsulta cada recibo poridpara averiguarlo y, si la API no lo devuelve, cae a FIFO (lo más viejo primero) declarándolo enestrategia. Ver PLAN_V2. - No loguea ni devuelve
BILLER_API_TOKEN; el audit no guarda el payload completo.
Tools disponibles
Lectura (read-only)
| Tool | Endpoint | Notas |
|---|---|---|
| biller_health_check | — | Diagnóstico. Reporta mode/environment. Nunca expone el token, y sin un remitente autorizado tampoco el RUT, la URL de la API ni la ruta del audit log (salen como booleanos). |
| biller_buscar_cliente_por_rut | /v2/dgi/empresas/* | Datos DGI. es_cliente_biller_confirmado siempre null. |
| biller_listar_comprobantes_emitidos | /v2/comprobantes/obtener | Filtros locales moneda/cliente_rut/limit y emitidas_desde/emitidas_hasta (por fecha de emisión fiscal). |
| biller_listar_comprobantes_recibidos | /v2/comprobantes/recibidos/obtener | Solo montos totales (sin items). |
| biller_obtener_comprobante | /v2/comprobantes/obtener | Por id, numero_interno o terna. Con id trae items[] tipado. |
| biller_obtener_pdf | /v2/comprobantes/pdf | Representación impresa en base64. Por defecto solo devuelve metadatos: pedí incluir_base64=true para el archivo. |
| biller_resumen_facturacion_periodo | /v2/comprobantes/obtener | Totales del período con agrupar_por. Ver Reportes. |
| biller_vencimientos | /v2/comprobantes/obtener | Aging por fecha_vencimiento + ranking de clientes. Monto bruto: no descuenta cobros. |
| biller_cuenta_corriente | /v2/comprobantes/obtener | Deuda neta (facturado − cobrado), por cliente y por factura. Recibos totales y parciales. |
| biller_alertas_operativas | /v2/comprobantes/obtener + /v2/dgi/empresas/certificado-unico | Rechazos DGI, CAE por agotarse/vencer, emisión tardía, racha sin facturar y certificado DGI. |
| biller_plata_en_riesgo | /v2/comprobantes/obtener | Las 6 alertas sobre el dinero: cliente en fuga, deudor grande, deuda hacia los 90 días, concentración en alza, mes por debajo, devoluciones disparadas. Cada una con acción y monto expuesto. |
| biller_ranking_clientes | /v2/comprobantes/obtener | Top, nuevos, dormidos, concentración (HHI) y ratio de notas de crédito. "Nuevo" exige detectar_nuevos=true: sin mirar antes del período no se puede saber, y es_nuevo viene en null en vez de adivinar. |
| biller_ranking_productos | /v2/comprobantes/obtener | Unidades e importe por producto + dispersión de precios entre clientes. N+1 acotado: declara cobertura_importe_pct. |
| biller_ranking_sucursales | /v2/comprobantes/obtener | Participación de cada local y su evolución en puntos contra el período anterior: muestra la sucursal que factura más y pesa menos. Nombres desde BILLER_SUCURSALES_JSON. |
| biller_cohortes_clientes | /v2/comprobantes/obtener | Retención por mes de alta: de los que entraron en marzo, cuántos siguen comprando. El "alta" es la primera compra del rango (Biller no expone fecha de alta), así que las primeras cohortes se marcan posible_contaminada. |
| biller_comparar_periodos | /v2/comprobantes/obtener | Variación por moneda, proyección de cierre (run-rate) y exposición cambiaria. |
| biller_compras_proveedores | /v2/comprobantes/recibidos/obtener | A quién le comprás y cuánto. Devengado, no pagado. |
| biller_requisitos_comprobante | — | "¿Qué necesito para emitir esto?" Devuelve los campos que faltan y una pregunta por vez. Sin red. Contempla la regla de las 5.000 UI. |
| biller_emision_guiada | — | El paso ANTERIOR a requisitos, para el chat: pregunta a quién se le factura y de ahí deduce el tipo de CFE (RUT → e-Factura, CI → e-Ticket). Devuelve una pregunta por vez con el mensaje tocable armado. Sin red. Ver FLUJO_WHATSAPP.md §3. |
| biller_plan_anulacion | /v2/comprobantes/obtener | "¿Cómo anulo esto?" NC para anular, ND para revertir la anulación. Detecta si ya tiene una NC encima. |
| biller_resolver_nombre | /v2/comprobantes/obtener | "Facturale a Distribuidora Peres" → quién es. Resuelve un nombre escrito a mano —con typo, abreviado, sin el "S.R.L."— contra los clientes y productos REALES de la empresa. Ante la duda devuelve candidatos y exige preguntar, en vez de elegir. Ver services/resolver.ts. |
| biller_reporte_diario | varios | El digest operativo, listo para WhatsApp. Con enviar=true lo manda vía Kapso (solo a números de la allowlist). |
| biller_catalogo_datos | — | Qué se puede preguntar y qué no, con la cobertura de cada cosa. |
| biller_metricas | — | Cómo viene funcionando el asistente: qué proporción de mensajes cae en "no entendí", en qué paso se abandonan las emisiones, qué tools fallan. NO toca la API ni devuelve datos de facturación. Ver Métricas. |
| biller_menu_whatsapp | — | El menú del asistente por WhatsApp y el enrutador de lo que escribe el usuario. Con enviar=true lo manda como lista interactiva tocable. Ver FLUJO_WHATSAPP.md. |
| biller_enviar_comprobante_whatsapp | /v2/comprobantes/obtener + /pdf | Adjunta el PDF de un CFE emitido a un WhatsApp, con el detalle armado desde el comprobante. El archivo no pasa por el contexto del modelo. Allowlist obligatoria. |
| biller_recordatorio_cobro | /v2/comprobantes/obtener | Le manda al cliente deudor su saldo. Única tool cuyo destinatario no es el usuario: exige dry-run → confirmation_token → confirm, allowlist, y no repite el envío al mismo cliente el mismo día. Si la imputación es FIFO reclama el total sin detallar facturas. Un cliente por invocación: no manda en lote. |
| biller_posicion_iva (opt-in) | /v2/comprobantes/obtener + recibidos | IVA ventas − IVA compras. No se registra por defecto: se parece a una declaración jurada sin serlo (BILLER_ENABLE_IVA_ESTIMADO=true). |
Escritura (readOnlyHint:false, destructiveHint:true)
| Tool | Endpoint |
|---|---|
| biller_emitir_comprobante | POST /v3/comprobantes/emitir (acepta confirmar_por_whatsapp: manda el preview como botones ✅/✖️) |
| biller_anular_comprobante | POST /v2/comprobantes/anular |
| biller_crear_cliente | POST /v2/clientes/crear |
| biller_cargar_producto | POST /v2/productos/cargar |
| biller_crear_recibo | POST /v2/recibos/crear |
| biller_cancelar_recibo | POST /v2/recibos/cancelar |
| biller_crear_pago | POST /v2/pagos/crear |
biller_listar_clientes (listado GET de clientes) no se registra: no hay
endpoint GET documentado (ver Pendientes).
Cómo se calcula cada número — fórmula por fórmula, y qué parte usa IA (respuesta corta: ninguna):
docs/CALCULOS.md. Arquitectura y diagramas:docs/ARQUITECTURA.md. La conversación por WhatsApp — qué pasa cuando llega un "hola", cómo se emite con botones y cómo llega el PDF:docs/FLUJO_WHATSAPP.md. Conexión con Kapso (transporte HTTP, tokens, despliegue):docs/KAPSO.md.
Reportes: cuánto vendí y dónde
biller_resumen_facturacion_periodo responde preguntas del tipo "¿cuánto vendí
en cada local en junio?".
{ "periodo": "2026-06", "agrupar_por": ["sucursal"] }periodo acepta 2026-06 (mes), 2026 (año), 2026-06-15 (día), hoy,
ayer, mes_actual, mes_pasado, ultimos_7_dias, ultimos_30_dias,
ultimos_90_dias, anio_actual. También podés pasar desde/hasta en aaaa-mm-dd.
agrupar_por admite sucursal, dia, mes, tipo_comprobante, moneda,
cliente y estado, y se pueden combinar (["sucursal","mes"] cruza local por mes).
Tres decisiones que hacen que los números coincidan con Biller:
- El período es por fecha de EMISIÓN fiscal. Los parámetros
desde/hastade la API filtran por fecha de creación (carga en Biller), así que una venta del 30/06 cargada el 02/07 quedaría afuera. La tool consulta por creación con un margen y después filtra localmente por emisión. solo_aceptadosviene entrue. El total cuenta solo los comprobantes en estado"Aceptado DGI", que es el criterio con el que Biller muestra sus totales. El total con todos los estados igual se devuelve entotales_por_moneda_todos_los_estadospara comparar.- Los rangos largos se parten en ventanas de 7 días (ajustable con
ventana_dias) y se unen deduplicando porid: la API no pagina y devuelve 500 con rangos amplios.
Para que los grupos digan Sucursal 6 (Pocitos) en vez de Sucursal 6,
configurá BILLER_SUCURSALES_JSON (Biller no expone un endpoint de sucursales).
Vencimientos: qué tengo que cobrar
biller_vencimientos responde "¿qué facturas vencen esta semana?" y "¿quién
me debe más?".
{ "horizonte_dias": 7 } // lo que vence en los próximos 7 días + lo ya vencidoDevuelve el detalle de facturas ordenado de la más vencida a la más lejana, los
totales por moneda separados en vencido y por vencer, el aging por tramos
(1-30, 31-60, 61-90, +90 días) y un ranking por_cliente con el monto vencido y
los días de atraso máximo.
⚠️ Es el monto bruto, no la deuda neta. Esta tool no descuenta los cobros: una factura ya cobrada aparece igual. Los recibos se detectan y se excluyen del listado (cobrar un recibo no tiene sentido), y si hay alguno en la ventana la respuesta avisa que el monto mostrado sobra.
cobranzas_imputadases siemprefalse. Para la deuda real usábiller_cuenta_corriente.
Tres decisiones que conviene conocer:
dias_atras(default 180) es la ventana de emisión que se consulta. Una factura que vence esta semana pudo emitirse hace meses; si trabajás con plazos más largos, subilo (cada 7 días es una llamada más a la API).solo_a_creditoviene entrue: descarta el contado con la heurísticafecha_vencimiento <= fecha_emision, porque Biller no expone la forma de pago en el GET. Pasáfalsepara incluirlo.- Solo cuentan ventas y notas de débito. Las notas de crédito restan deuda, no se cobran, así que no se listan como cobrables.
Cuenta corriente: quién me debe plata
biller_cuenta_corriente responde lo que vencimientos no puede: lo facturado a
crédito menos lo cobrado, por cliente y por factura.
La clave es que un recibo es un CFE: se emite como e-Ticket (101) o e-Factura
(111) y vuelve en el mismo GET /v2/comprobantes/obtener, marcado con
indicador_cobranza_propia = 1. Puede ser total, parcial o un "Adelanto" sin
referencias. Con eso, facturas, notas de crédito y cobros salen de una sola
consulta.
{ "dias_atras": 365 } // toda la deuda abierta del último año
{ "cliente_rut": "217832560011" } // el estado de cuenta de un clienteDevuelve por cada factura total, cobrado, saldo y estado_cobro
(pendiente / parcial / cancelada), el aging calculado sobre el saldo neto,
y un ranking por_cliente con saldo, vencido y días de atraso.
Dos niveles de precisión, y la respuesta dice cuál usó (estrategia):
| Nivel | Precisión | Costo |
|---|---|---|
| Saldo por cliente | Exacto siempre | 0 llamadas extra |
| Saldo por factura | Exacto si estrategia: "referencias"; estimado si "fifo"/"mixta" | 1 llamada por recibo |
El listado no trae a qué factura se imputó cada cobro, así que la tool consulta
cada recibo por id (N+1 solo sobre recibos, que son bastantes menos que las
facturas; apagable con imputar_por_referencias=false). Si la API no devuelve las
referencias, imputa FIFO —lo más viejo primero dentro de cada cliente+moneda,
el criterio contable estándar— y lo declara como estimación en estrategia y en
los warnings.
El cobro que no entra en ninguna factura abierta (un adelanto, o una factura
anterior a la ventana) no se fuerza: va a saldo_a_favor_por_moneda. Bajar el
saldo con plata que no le corresponde a esa factura sería peor que no imputarla.
Alertas operativas
biller_alertas_operativas barre un período y devuelve lo que hay que atender,
usando campos que la API ya devuelve en cada comprobante y que normalmente no
se miran hasta que rompen:
- Rechazos DGI (
estado): un CFE "Rechazado DGI" no tiene validez fiscal — la venta figura en el sistema y no existe ante DGI. Severidadcritica. "Pendiente DGI" y similares salen comoadvertencia. - CAE por agotarse o vencer (
cae.fin,cae.fecha_expiracion): cuando se agota el rango autorizado o expira el CAE, la facturación se corta. Avisa con ≤500 números o ≤45 días (advertencia) y ≤100 números o ≤15 días (crítico).
{ "periodo": "ultimos_30_dias", "severidad_minima": "advertencia" }Los números de CAE disponibles son una estimación optimista: solo se ven los comprobantes del período consultado, así que el último número usado puede ser mayor al observado. Períodos más amplios dan una estimación más ajustada.
Campos del comprobante (lectura)
El OpenAPI público documenta ~18 campos, pero la API real devuelve ~35. El
normalizador los expone todos con tipos estables (los números llegan como string,
p.ej. "38.397", y se convierten a número). Lo más útil:
| Campo | Tipo | Notas |
|---|---|---|
| estado | string | Estado ante DGI: "Aceptado DGI", "Rechazado DGI", "Sobre Rechazado DGI", "Pendiente DGI", "Envío no corresponde". No documentado en el OpenAPI. |
| tasa_cambio | number | Cotización del día para moneda extranjera (ej. USD 38.397). En UYU = 1. |
| sucursal | number | ID real de la sucursal emisora. |
| numero_interno | string|null | Identificador propio de la empresa. |
| moneda / total | string→number | Moneda y total del comprobante. |
| montos_brutos | number | Flag 0/1: si los precios de los ítems incluyen IVA. |
| iva | objeto | Subtotales por tasa (tasa_minima/tasa_basica/tasa_otra). |
| adenda, informacion_adicional, numero_orden, lugar_entrega | string | Texto libre del comprobante. |
| razon_referencia, referencia_global, retenciones_percepciones | varios | Referencias a otros CFE y retenciones. |
| cliente | objeto crudo | Receptor (id, tipo_documento, documento/RUT, razon_social, sucursal). |
| items | array | Solo al consultar con id. Cada ítem: codigo, concepto, cantidad, precio, indicador_facturacion, impuesto_tasa, descuentos/recargos y retenciones_percepciones. |
| campos_presentes | string[] | Todas las claves crudas que vinieron en la respuesta. |
| campos_extra | objeto | Red de seguridad: cualquier campo que la API devuelva y el normalizador aún no tipe aparece acá (no se pierde nada). |
Estado y facturación. El
resumen_facturacion_periodosuma todos los estados y agregaconteo_por_estado+ un warning si el total incluyeRechazado DGI/Pendiente DGI. No existe un estado "Anulado": anular un CFE genera una Nota de Crédito separada (que ya resta en el total).
Fechas. Los filtros
desde/hastade la API filtran por fecha de creación (carga en Biller). Para acotar por fecha de emisión fiscal usá los filtros localesemitidas_desde/emitidas_hasta(avisan si excluyen comprobantes sinfecha_emision).
Escritura con barreras
Cada tool de escritura funciona en dos fases:
1. Dry-run (default, confirm ausente o false) — valida el cuerpo, arma el
payload exacto, y devuelve un preview + un confirmation_token.
No hace ninguna llamada de red.
{
"mode": "dry_run",
"endpoint": "/v3/comprobantes/emitir",
"environment": "test",
"write_enabled": false,
"gate": { "allowed": false, "reason": "write_disabled", "requires_allow_production": false },
"payload_preview": { "tipo_comprobante": 101, "sucursal": 6, "items": [ /* ... */ ] },
"totales_estimados": { "subtotal": 200, "iva_por_tasa": { "22": 44 }, "total": 244, "exacto": true },
"resumen": "Total estimado: UYU 244 (neto 200 — IVA 22%: 44)",
"confirmation_token": "a1b2…(sha256)",
"next_step": "Para EJECUTAR, volvé a llamar … con confirm=true y confirmation_token=\"a1b2…\"",
"no_network_call": true,
"warnings": []
}El preview calcula el total localmente (totales_estimados + la línea
resumen) para que confirmar no sea a ciegas. Es una estimación: el total
autoritativo es el que devuelve Biller. Si algún ítem usa una tasa que no se
puede determinar, exacto baja a false y se explica en advertencias.
Los documentos y contactos del receptor se enmascaran parcialmente
(2149874400**) en vez de ocultarse: hace falta reconocer a quién se le factura
para poder confirmar.
2. Ejecución (confirm: true + confirmation_token) — recién acá puede hacer el
POST, y solo si pasan todas las barreras:
- Token: el
confirmation_tokendebe coincidir con el payload+endpoint+ambiente. Si cambiás cualquier campo, el token deja de valer → hay que volver a previsualizar. - Gate de escritura:
BILLER_WRITE_ENABLED=true. - Gate de producción: si el ambiente es
production, ademásBILLER_ALLOW_PRODUCTION_WRITES=truey el argumentoallow_production=true. - Idempotencia: una misma
idempotency_keyno se ejecuta dos veces en la sesión (también se envía como headerIdempotency-Key). Además, si el comprobante traenumero_interno, antes de emitir se consulta a Biller si ese número ya se usó y se aborta la emisión si existe — es la única defensa que sobrevive a un reinicio del servidor (desactivable converificar_duplicado=false). - Audit log: cada intento/ejecución se registra (a stderr y, opcional, a archivo)
con
audit_id, endpoint, ambiente, hash del payload y estado — nunca el token ni el payload completo.
Flujo típico con el asistente: pedís la operación → el MCP devuelve el preview →
revisás → confirmás → el asistente reenvía con confirm:true + token → se ejecuta.
Instalación
Requisitos: Node.js ≥ 18.17 (usa fetch nativo).
git clone https://github.com/MateoFabregat/MCP_Biller.git
cd MCP_Biller
npm ci
cp .env.example .env
npm run buildCompletar .env con BILLER_API_BASE_URL y BILLER_API_TOKEN. Para pruebas,
usar https://test.biller.uy y dejar BILLER_CAPABILITY_MODE=read_only.
Hay un ejemplo de configuración para Claude Desktop en
claude_desktop_config.example.json.
Configuración (.env)
Copiá .env.example a .env. Empezá siempre por TEST. El .env está en
.gitignore; no commitees tokens.
| Variable | Requerida | Default | Descripción |
|---|---|---|---|
| BILLER_API_BASE_URL | Sí | — | https://test.biller.uy o https://biller.uy. |
| BILLER_API_TOKEN | Sí | — | Bearer token de la empresa. Nunca se loguea ni se devuelve. |
| BILLER_CAPABILITY_MODE | No | read_only | read_only (solo lectura) | write_enabled (+ tools de escritura). |
| BILLER_DEFAULT_EMPRESA_RUT | No | — | Metadata local; no se envía a la API. |
| BILLER_DEFAULT_SUCURSAL_ID | No | — | Default de sucursal (lectura y emisión). ID real de Biller (Ajustes → Sucursales), no un valor genérico. Opcional: obtener no lo exige. |
| BILLER_SUCURSALES_JSON | No | — | Mapa {"6":"Pocitos","7":"Centro"} para nombrar sucursales en los reportes. Biller no expone un endpoint de sucursales. |
| BILLER_TIMEOUT_MS | No | 30000 | Timeout HTTP (ms). |
| LOG_LEVEL | No | info | error|warn|info|debug (logs a stderr). |
| BILLER_WRITE_ENABLED | No | false | Gate de ejecución POST. Sin esto, solo dry-run (requiere write_enabled). |
| BILLER_ALLOW_PRODUCTION_WRITES | No | false | Habilita POST contra producción (+ allow_production=true). |
| BILLER_AUDIT_LOG_PATH | No | — | Archivo opcional para el audit log de escrituras. |
Build, test y guard
npm run build # tsc -> dist/
npm test # vitest (mocks; sin red real)
npm run typecheck # tsc --noEmit
npm run check:readonly # falla si hay POST/PUT/PATCH/DELETE FUERA de la capa write/Conectar a Claude Desktop
~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"biller": {
"command": "node",
"args": ["/ruta/ABSOLUTA/MCP_Biller/dist/index.js"],
"env": {
"BILLER_API_BASE_URL": "https://test.biller.uy",
"BILLER_API_TOKEN": "tu-token-de-TEST",
"BILLER_WRITE_ENABLED": "false"
}
}
}
}BILLER_DEFAULT_SUCURSAL_ID es opcional y se omite arriba a propósito:
GET /v2/comprobantes/obtener no requiere sucursal. Si querés fijar una por
defecto, usá el ID real de tu sucursal (Ajustes → Sucursales en
{ambiente}.biller.uy), no un valor genérico como 1.
Para habilitar escritura en test: agregá "BILLER_CAPABILITY_MODE": "write_enabled" y
"BILLER_WRITE_ENABLED": "true". Aun así, cada emisión/anulación requiere el flujo
dry-run → confirm con token.
Conectar a Claude Code
npm run build
claude mcp add biller \
--env BILLER_API_BASE_URL=https://test.biller.uy \
--env BILLER_API_TOKEN=tu-token-de-TEST \
-- node /ruta/ABSOLUTA/MCP_Biller/dist/index.js
# Opcional: --env BILLER_DEFAULT_SUCURSAL_ID=<ID real de Ajustes → Sucursales>Probar con MCP Inspector
npm run inspector # = npm run build && npx @modelcontextprotocol/inspector node dist/index.jsProbá biller_health_check (mirá capability_mode/write_tools_registered/environment).
Para probar escritura, pasá BILLER_CAPABILITY_MODE=write_enabled al inspector y verificá
que aparezcan las tools de escritura. Después llamá biller_emitir_comprobante en
dry-run y verificá el confirmation_token.
Métricas: cómo sabemos que anda
Hasta acá había 900+ tests y ninguna métrica: no se sabía cuántos mensajes caían en "no entendí" ni en qué paso se abandonaban las emisiones. La prueba de que eso no alcanzaba es concreta — una prueba a mano encontró que siete de siete frases reales caían en "no entendí", con la suite entera en verde.
biller_metricas contesta las tres preguntas que faltaban:
Mensajes: 10 | No entendidos: 20%
2 via=saludo opcion=ninguna
2 via=desconocido opcion=ninguna
1 via=sinonimo opcion=emitir
1 via=aproximado opcion=cobranzasenrutador.mensaje{via}— cuánto NO se entiende. Por encima del 20% la gente deja de escribir.emision.paso{paso}— el embudo. Si 100 llegan a "cliente" y 12 a "confirmar", se abandonan 88 emisiones y se ve en qué pregunta.resolver.consulta{clase}— cuántas veces hay que repreguntar.
Acá no entra un dato fiscal, y la garantía es estructural. Las métricas salen por un canal (stderr, un agregador de logs, esta tool) que no pasa por la barrera de salida ni por la de entrada: un dato que se filtre por acá no lo ve ninguna de las dos. Por eso los nombres son una unión cerrada de TypeScript, los valores se validan contra un patrón estrecho, y las corridas de 8+ dígitos —RUT, CI, teléfono, número de comprobante— se rechazan aunque pasen ese patrón.
Ese último filtro lo agregó un test que probó un RUT y pasó. "El filtro rechaza texto libre" no era lo mismo que "el filtro rechaza datos de un cliente"; se habían tratado como la misma cosa.
La instrumentación intercepta registerTool, igual que las barreras de entrada y
salida: toda tool futura queda medida sin que su autor se acuerde de nada.
En serverless los contadores casi no sirven (el proceso muere entre
invocaciones): ahí la fuente de verdad son las líneas "msg":"metrica" del log,
que se emiten igual. La respuesta lo dice en alcance en vez de dejar que se
asuma.
El borrador de emisión ya no vive en el contexto del modelo
Emitir una factura por WhatsApp son diez o doce mensajes. Hasta acá el contrato era "el agente manda TODO lo que sabe en cada llamada", y el estado vivía en el contexto del modelo — o sea que el flujo más caro del producto se apoyaba en lo menos confiable que hay. Un agente que se olvida un campo hace que al usuario le vuelvan a preguntar lo mismo; si se olvida el concepto del ítem, no hay forma de notarlo y el CFE sale sin esa línea.
Pasándole sesion (el número de la conversación) a biller_emision_guiada, el
server guarda el borrador y lo usa como base sobre la que aplica lo que
llegue nuevo:
// mensaje 1
{ "sesion": "+598…", "clase_receptor": "empresa", "documento": "210000000011" }
// mensaje 2 — alcanza con el dato nuevo
{ "sesion": "+598…", "fecha_emision": "17/08/2026" }Cuatro decisiones que vale la pena conocer:
- La clave no es el teléfono, es un hash. El número es un dato personal de un tercero y terminaría en el archivo y en cada log que mencione la clave.
undefinedes "no me dijeron nada", nunca "borralo". Si se confundieran, cada llamada incompleta vaciaría medio borrador — el problema que el store vino a resolver.- Un borrador vencido no se reanuda, se descarta (24 h). Uno de hace tres días trae la fecha y los precios de hace tres días: reanudarlo en silencio es emitir un comprobante que el usuario cree que es de hoy.
- El borrador es de quien lo está cargando. Con Kapso configurado,
sesiontiene que resolver al mismo usuario que la barrera de entrada ya verificó: unsesionajeno se rechaza, no se abre. La empresa suele tener dos números autorizados —el dueño y el contador—, y sin esta regla "seguí la factura que estaba armando el 099…" alcanzaba para leerle el borrador al otro, agregarle líneas y emitir un CFE real con sus datos. Conremitentealcanza: el server ya sabe de quién es el borrador. - Usá el
sesion.idque devuelve la tool, no el teléfono. El mismo número escrito de dos formas —099 123 456y+598 99 123 456— son dos sesiones, yconfig.tsya decidió que adivinarle el código de país a un número uruguayo es peor que avisar. Elides opaco y exacto: no se puede escribir de dos formas. - El borrador se descarta al emitir, no antes. Pasale ese mismo
sesion.idabiller_emitir_comprobante— si no hay borrador con esa clave, el dry-run te avisa, que es cuando todavía se puede corregir. Se borra solo conmode: executedy un2xx: un dry-run o un 422 lo conservan, porque ese es justo el momento en que más vale.
La persistencia a disco es opt-in (BILLER_BORRADOR_STORE_PATH), al revés
que la de idempotencia. Esa guarda solo una key; esta guardaría qué se vendió, a
quién y la adenda — información comercial de la empresa y datos de sus clientes.
En stdio y en el server HTTP largo la memoria alcanza de sobra.
Seguridad y límites
- Aislamiento de escritura: todo el código que hace
POSTvive ensrc/write/. El guard estático (npm run check:readonly+tests/readonly.test.ts) falla si aparece escritura en cualquier otro lado: la superficie de lectura es GET-only. - Escritura apagada por defecto + dry-run + confirmación + doble gate de producción + idempotencia + audit log.
- Token protegido: nunca se loguea ni se devuelve; se redacta de los errores
(
[REDACTED]). El audit guarda un hash del payload, no el payload. - Aislamiento entre empresas: con varias empresas en un proceso, el overlay
de un tenant no hereda lo sensible que no declara (las
KAPSO_*, la allowlist de remitentes, los flags de escritura, la identidad fiscal): se borra del entorno base, porque borrar hace el error imposible y exigir que se declare solo lo hace detectable. Las rutas de persistencia y los topes de monto van al revés —borrarlas afloja—, así que si el proceso las define y un tenant no declara la suya, el server no arranca. Tampoco arranca con el mismoBILLER_API_TOKENen dos tenants ni con dos apuntando al mismo archivo. - El borrador de emisión es de quien lo carga: con el canal de WhatsApp
abierto, la barrera inyecta el remitente ya verificado y un
sesionque apunte a otro número se rechaza. Dentro de una misma empresa hay normalmente dos teléfonos autorizados, y sin esto uno podía leer, editar y emitir con el borrador del otro. - stdout reservado para MCP; los logs van a stderr.
- Rate limits (Biller): 1 req/seg para DGI, recibidos y creación/anulación de
comprobantes y recibos; 30 req/seg para el resto. El
429se mapea claro. - El resumen de facturación mantiene los totales separados por moneda. No calcula un total consolidado en UYU/USD.
Pendientes de validación contra Biller
No documentado en el OpenAPI público (no se inventó):
- Endpoint GET de listado de clientes →
biller_listar_clientesno se registra. (Sí existe la escriturabiller_crear_cliente.) - Paginación de
/v2/comprobantes/obtener→limites recorte local;pagination_supported: false. - Estado de anulación → la API expone
estado(Aceptado/Rechazado/Pendiente DGI), pero no un estado "Anulado": anular genera una Nota de Crédito separada. El resumen lo aclara y desglosa por estado en vez de intentar filtrar anulados. - Estructura real de
clienteen emitidos → se preserva cruda; el filtrocliente_rutla recorre buscando eldocumento/RUT. - Filtros nativos de moneda/cliente → se hacen locales.
- Semántica de fechas (
desde/hasta) → filtran porfecha_creacion. Para la fecha de emisión fiscal hay filtros localesemitidas_desde/emitidas_hasta. - Soporte del header
Idempotency-Keyserver-side → la idempotencia fuerte es in-process; la defensa que sí persiste es el chequeo denumero_interno. - Esquema de request de
POST /v3/comprobantes/emitir→ la doc trae la Tabla de Valores completa y 12 ejemplos, pero no un JSON Schema. El cuerpo se valida contra esa tabla (ver abajo) y los campos no documentados pasan sin tocarse. Los demás POST (recibos, pagos, clientes, productos, anular) sí declaran schema conrequired, y eserequiredse respeta literalmente.
Validación del CFE
src/biller/cfeSchema.ts tipa la Tabla de Valores completa: los 22 tipos de CFE,
forma_pago, los 16 indicador_facturacion, tipo_documento, modalidad_venta,
clausula_venta, via_transporte, tipo_traslado, indicador_agente_responsable,
y los largos máximos de cada campo.
El criterio de estrictez es deliberado:
- Error solo donde la doc dice "Obligatorio" o "Mutuamente excluyente":
exportaciones sin
modalidad_venta/clausula_venta/via_transporte/ncm, remitos sintipo_traslado,referenciasjunto conreferencia_global,referencia_globalsinrazon_referencia, retenciones en CFE que no las admiten, y fechas fuera de rango. - Warning para todo lo demás (e-Factura sin receptor, nota de ajuste sin
referencia, falta de
numero_interno…): se informa en el preview y el humano decide. - Passthrough para los campos que la doc no lista: nunca se descartan datos.
Ojo con las fechas: la API usa DOS formatos.
fecha_emisionyfecha_vencimientode un CFE van en dd/mm/aaaa; las fechas de recibos y los filtros de lectura van en aaaa-mm-dd;fechade un pago acepta ambos. Cada schema valida el suyo y el mensaje de error aclara cuál corresponde.
Roadmap
Lo que ya no está acá porque se hizo: transporte Streamable HTTP, canal de WhatsApp (Kapso), multi-tenant, barreras de entrada y salida.
Lo que falta, en orden de lo que bloquea a lo que no (los dos primeros del
backlog histórico —observabilidad y estado persistente del borrador— ya están:
biller_metricas y el store de sesión):
- Un usuario real con las métricas prendidas. Todo lo de abajo son
hipótesis hasta que
enrutador.mensajey el embudoemision.pasotengan datos de producción. - Templates de WhatsApp. El push proactivo fuera de la ventana de 24 h los necesita, y el sandbox de Kapso no los tiene. Bloquea el cierre de mes proactivo (BRAINSTORM V5.5).
- Migrar los schemas a Zod v4 (BRAINSTORM V5.4): borra el parche de
dialecto (
transport/dialecto.ts) y emite JSON Schema 2020-12 nativo. - Resource MCP con catálogo de tipos de CFE.
- Validar paginación / filtros nativos / endpoint GET de clientes cuando existan.
Fuente
Documentación oficial de la API de Biller (OpenAPI 3.0), servidores
https://test.biller.uy y https://biller.uy.
