nexusflex-mcp 3.1.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/docs.mjs +322 -0
  2. package/package.json +28 -7
  3. package/server.mjs +415 -2
package/docs.mjs ADDED
@@ -0,0 +1,322 @@
1
+ // ============================================================================
2
+ // Documentación integrada del MCP de Nexus Flex — fuente de verdad de la ayuda
3
+ // por herramienta (tool `ayuda`). Cada tool trae: qué hace, cómo usarlo (params),
4
+ // un ejemplo resuelto y qué NO hace (para no inferir comportamiento inexistente).
5
+ //
6
+ // ⚠️ ESPEJO: este archivo está DUPLICADO en `backend/src/services/mcp-docs.ts`
7
+ // (el conector remoto se deploya sin la carpeta mcp/). Si editás uno, editá el
8
+ // otro — igual que MCP_VERSION/NOVEDADES. Mantené el contenido idéntico.
9
+ // ============================================================================
10
+
11
+ // Tópicos transversales (no son tools) — se consultan con `ayuda tema:<clave>`.
12
+ export const TOPICS = {
13
+ facturacion_marketplace: [
14
+ "FACTURACIÓN DEL MARKETPLACE DE RUTAS PÚBLICAS",
15
+ "",
16
+ "Adjudicar una ruta (ruta_adjudicar) es OPERATIVO: cierra la subasta, marca el",
17
+ "nodo ganador (nodoToma) y deja registrada la OBLIGACIÓN al precio de la oferta",
18
+ "(precioAcordado). NO crea ningún asiento automático de facturación semanal, ni",
19
+ "en rendiciones, ni en comisiones de afiliado, ni en el clearing entre nodos.",
20
+ "El marketplace NO mueve dinero.",
21
+ "",
22
+ "Es decir: adjudicar ≠ colecta. Una colecta entre nodos sí genera un ítem de",
23
+ "clearing; una ruta adjudicada, NO. El pago entre el nodo que publica y el que",
24
+ "toma se acuerda y se salda MANUALMENTE (efectivo con el cadete, o descontándolo",
25
+ "de lo que un nodo ya le debe al otro por otros conceptos).",
26
+ "",
27
+ "Cómo verificarlo desde el MCP:",
28
+ " • mis_rutas → estado 'adjudicada', nodoToma y precioAcordado de cada ruta tuya.",
29
+ " • rendiciones / comisiones_afiliado_ver → NO listarán la ruta adjudicada.",
30
+ "",
31
+ "Qué falta (roadmap, todavía NO implementado): un settlement automático que,",
32
+ "al adjudicar, cargue el precioAcordado en la cuenta corriente entre los dos",
33
+ "nodos. Mientras no exista, tratá el pago como manual y avisáselo al usuario.",
34
+ ].join("\n"),
35
+
36
+ dinero: [
37
+ "DINERO — QUÉ NO HACE NUNCA EL MCP",
38
+ "",
39
+ "El MCP nunca mueve plata: no registra pagos de vendedores, no factura, no",
40
+ "concilia caja, no imputa cobros/haberes en cuentas corrientes. Hay una denylist",
41
+ "de dinero (mcpMoneyGuard) heredada del backend. Las únicas escrituras 'con",
42
+ "olor a plata' son configuraciones de TARIFA (colecta_configurar, precio_",
43
+ "actualizar, afiliacion_crear) o el REGISTRO de una obligación (comision_liquidar",
44
+ "marca comisiones ya devengadas como pagadas; ruta_adjudicar deja el precio",
45
+ "acordado). Ver tema facturacion_marketplace.",
46
+ ].join("\n"),
47
+
48
+ aislamiento: [
49
+ "AISLAMIENTO ENTRE NODOS",
50
+ "",
51
+ "Cada token de MCP pertenece a un usuario. Todos los tools llaman a la API REST",
52
+ "del backend con ese token, así que heredan el MISMO gateo que la web:",
53
+ "requireAuth + requirePermiso + scope por nodo/idCliente. Un vendedor solo ve",
54
+ "SUS envíos/stock; un operador solo su nodo; el admin global ve la red. Ningún",
55
+ "tool puede leer ni escribir datos de otro nodo (salvo lo que es público por",
56
+ "diseño, como el marketplace de rutas abiertas). Si un tool devuelve 403 es el",
57
+ "aislamiento del backend, no un bug.",
58
+ ].join("\n"),
59
+ };
60
+
61
+ // Guías de arranque por rol (onboarding). Se muestran al conectar (server
62
+ // `instructions`) y con el tool `guia`. Metodología para empezar a usar el sistema
63
+ // rápido y bien, con las herramientas concretas de cada paso.
64
+ export const GUIAS = {
65
+ mensajero: [
66
+ "GUÍA DEL MENSAJERO — registrar lo que colectás, en el momento",
67
+ "",
68
+ "La idea: cuando pasás a colectar a un cliente, en vez de anotar a mano, me",
69
+ "mandás las FOTOS y yo cargo los envíos por vos. Paso a paso:",
70
+ "",
71
+ "1) Sacá una FOTO de cada paquete que agarrás: la etiqueta (Flex/Mercado Libre",
72
+ " o la que sea) y, si tiene QR, que se vea. Si el paquete NO tiene etiqueta,",
73
+ " sacá foto igual (datos del paquete: a quién va, dirección, teléfono).",
74
+ "2) Mandámelas todas juntas (un grupo de fotos). Yo leo cada una y registro el",
75
+ " envío del cliente que estás colectando:",
76
+ " • Con etiqueta ML / QR → lo registro con `envio_desde_etiqueta_ml`",
77
+ " (guardo el QR real, la etiqueta queda reimprimible).",
78
+ " • Sin etiqueta, con datos → lo registro con `envio_cargar` como pedido",
79
+ " manual y se le puede generar una etiqueta NUEVA para pegar.",
80
+ "3) IMPORTANTE (aislamiento): SOLO podés registrar envíos de clientes de TU",
81
+ " nodo, o de un cliente que tengas COLECTADO (incluye colectas tomadas del",
82
+ " marketplace de colectas). De otros clientes, no.",
83
+ "4) Cada envío queda 'A retirar', atribuido a vos, con un LINK de etiqueta",
84
+ " imprimible. El vendedor lo ve al toque en su cuenta.",
85
+ "5) Al llegar al nodo, el centro procesa los envíos; a los que cargaste sin",
86
+ " etiqueta se les pega una y salen normal. Después se escanean, aparecen en",
87
+ " el mapa y se te suma la tarifa como cualquier reparto.",
88
+ "",
89
+ "Tips: si una etiqueta se rompió o no se lee, cargá el envío con `envio_cargar`",
90
+ "y generás una nueva. Consultá el estado de un envío con `envio_consultar`, tu",
91
+ "ruta con `mi_ruta` y tus colectas con `mis_colectas`.",
92
+ "(Próximamente: aviso en la app para imprimir de una todas las etiquetas que",
93
+ "cargaste por acá.)",
94
+ ].join("\n"),
95
+
96
+ cliente: [
97
+ "GUÍA DEL VENDEDOR — cargá tus ventas y seguí tu operación desde acá",
98
+ "",
99
+ "1) Cargar una venta/envío: `envio_cargar` con destinatario, teléfono,",
100
+ " dirección y localidad (montoCobro si cobrás contra entrega). Te devuelvo",
101
+ " el tracking + un link de etiqueta para imprimir + un link para subir una",
102
+ " foto del paquete.",
103
+ "2) ¿Vendés por Mercado Libre? Mandame la foto de la etiqueta y la registro",
104
+ " con `envio_desde_etiqueta_ml` (queda con el QR real).",
105
+ "3) Colecta (que te retiren): `mi_colecta` para ver cómo estás, `colecta_auto`",
106
+ " para prender/apagar la automática, `colecta_solicitar` para una por única vez.",
107
+ "4) Tu negocio: `mi_stock` / `mi_disponible` (para no sobrevender), ",
108
+ " `mi_rentabilidad`, `mis_top_productos`, `mis_kpis`. Sucursales de retiro con",
109
+ " `mis_sucursales` / `sucursal_guardar`.",
110
+ "",
111
+ "Todo lo tuyo es solo tuyo (aislamiento). Nunca toco dinero por acá.",
112
+ ].join("\n"),
113
+
114
+ nodo: [
115
+ "GUÍA DEL NODO (operador/admin) — implementar y operar Nexus Flex rápido",
116
+ "",
117
+ "Puesta a punto:",
118
+ " • Vendedores: `cliente_crear` / `cliente_editar` (asigná su lista de precio).",
119
+ " • Choferes: `chofer_crear` (te devuelvo una clave temporal para pasarle).",
120
+ " • Colecta: `colecta_configurar` (pago del nodo/mensajero, cobro por vendedor).",
121
+ " • Zonas: `asignar_mensajero_zona` (mensajero↔metazona), `asignar_nodo_zona`",
122
+ " (un nodo de tu grupo cubre una localidad).",
123
+ "",
124
+ "El día a día:",
125
+ " • `colecta_pendientes` (hoy + mañana) y `colecta_asignar` (que Fulano levante",
126
+ " a tal cliente).",
127
+ " • Envíos: los cadetes pueden cargar lo que colectan por MCP (foto→envío); vos",
128
+ " los ves 'A retirar'. Consultá con `envio_consultar`, corregí con",
129
+ " `cobro_corregir` / `envio_reasignar_cliente`.",
130
+ " • Rendiciones: `rendiciones` (qué falta recuperar/rendir).",
131
+ "",
132
+ "Clearing entre nodos (eficiente):",
133
+ " • Metazonas + grupos logísticos rutean los envíos al nodo que cubre la zona.",
134
+ " Recibís lo que te rutean con `envios_por_zona` → `procesar_zona` (aceptar) o",
135
+ " `rechazar_zona`. El clearing sale de la config de tarifas por grupo.",
136
+ " • Marketplace de rutas (`rutas_publicas`, `ruta_publicar`/`ofertar`/`adjudicar`):",
137
+ " subasta abierta entre nodos. OJO: adjudicar NO factura automático — el pago",
138
+ " entre nodos se salda a mano (ver `ayuda tema:facturacion_marketplace`).",
139
+ " • Números: `kpi_nodo` (P&L, top clientes), `metricas_por_tipo`.",
140
+ "",
141
+ "Aislamiento: todo lo que ves/hacés es de TU nodo (o tu grupo cuando corresponde).",
142
+ ].join("\n"),
143
+ };
144
+
145
+ // Devuelve la guía de arranque para un rol (mensajero/cliente/operador/admin).
146
+ export function guiaOnboarding(rol) {
147
+ if (rol === "mensajero") return GUIAS.mensajero;
148
+ if (rol === "cliente") return GUIAS.cliente;
149
+ if (rol === "operador" || rol === "admin") return GUIAS.nodo;
150
+ return [GUIAS.cliente, "", "— — —", "", GUIAS.nodo].join("\n");
151
+ }
152
+
153
+ // Orden y agrupación para el índice (solo se muestran los que el usuario tiene).
154
+ export const GROUPS = [
155
+ { titulo: "General", tools: ["mis_datos", "guia", "ayuda", "mcp_version", "sugerencia_crear"] },
156
+ { titulo: "Vendedor — mi operación", tools: ["mis_envios", "envio_consultar", "mis_sucursales", "sucursal_guardar", "mi_colecta", "colecta_solicitar", "colecta_auto"] },
157
+ { titulo: "Vendedor — mi stock y números", tools: ["mi_stock", "mi_disponible", "mi_rentabilidad", "mis_productos", "mis_top_productos", "mis_kpis"] },
158
+ { titulo: "Mensajero", tools: ["mi_ruta", "mis_colectas"] },
159
+ { titulo: "Envíos (staff)", tools: ["envio_cargar", "envio_desde_etiqueta_ml", "envio_reasignar_cliente", "cobro_corregir"] },
160
+ { titulo: "Colecta y zonas (staff)", tools: ["colecta_ver", "colecta_pendientes", "colecta_configurar", "colecta_asignar", "colecta_desasignar", "zonas_reparto", "asignar_mensajero_zona", "asignar_nodo_zona", "envios_por_zona", "procesar_zona", "rechazar_zona"] },
161
+ { titulo: "Rendiciones y reclamos", tools: ["rendiciones", "rendicion_revertir", "reclamos_listar"] },
162
+ { titulo: "Marketplace de rutas", tools: ["rutas_publicas", "mis_rutas", "ruta_ofertas", "ruta_publicar", "ruta_ofertar", "ruta_adjudicar"] },
163
+ { titulo: "Afiliados", tools: ["afiliacion_listar", "comisiones_afiliado_ver", "afiliado_crear", "afiliacion_crear", "afiliacion_editar", "comision_liquidar"] },
164
+ { titulo: "Métricas / KPIs", tools: ["kpi_nodo", "kpi_red", "metricas_por_tipo", "top_productos"] },
165
+ { titulo: "WMS del nodo", tools: ["stock_nodo", "productos_nodo", "producto_crear"] },
166
+ { titulo: "Gestión del nodo", tools: ["clientes_del_nodo", "cliente_crear", "cliente_editar", "precios_ver", "precio_actualizar", "cuentas_vinculadas", "generar_enlace_vinculacion"] },
167
+ { titulo: "Usuarios / choferes", tools: ["chofer_listar", "chofer_crear", "chofer_editar", "usuario_habilitar_reparto"] },
168
+ { titulo: "Administración (red)", tools: ["nodos_listar", "nodo_crear", "sugerencias_listar"] },
169
+ ];
170
+
171
+ // Doc por herramienta: que=qué hace, uso=cómo/params, ej=ejemplo resuelto, no=qué NO hace.
172
+ export const TOOL_DOCS = {
173
+ ayuda: { que: "Devuelve esta documentación: índice de herramientas o la ficha completa de una (qué hace, params, ejemplo, qué NO hace) y tópicos transversales.", uso: "Sin argumentos = índice. `tool:<nombre>` = ficha de esa herramienta. `tema:<clave>` = tópico (facturacion_marketplace, dinero, aislamiento).", ej: "ayuda tool:ruta_adjudicar → te explica cómo adjudicar y aclara que no mueve dinero.", no: "No ejecuta nada ni consulta datos; es solo lectura de documentación estática." },
174
+ guia: { que: "Guía de ARRANQUE para tu rol: la metodología para empezar a usar Nexus Flex rápido, con las herramientas de cada paso (mensajero: foto→envío al colectar; vendedor: cargar ventas; nodo: implementar + operar + clearing).", uso: "Sin argumentos = tu guía según tu rol. `rol:<mensajero|cliente|nodo>` para ver otra.", ej: "Recién conectás como mensajero → guia → te explico cómo registrar lo que colectás sacando fotos.", no: "No ejecuta acciones; es la metodología. Para el detalle de una herramienta usá `ayuda tool:<x>`." },
175
+ mis_datos: { que: "Tu usuario, rol y nodo/cliente: define tu ALCANCE (qué podés ver/hacer).", uso: "Sin parámetros.", ej: "Antes de operar, mis_datos → confirmás que sos operador del nodo 6 y qué permisos tenés.", no: "No lista otros usuarios ni cambia nada." },
176
+ mcp_version: { que: "Versión del MCP corriendo, por qué vía (remoto/npx) y las novedades recientes.", uso: "Sin parámetros.", ej: "El usuario pregunta 'tengo lo último' → mcp_version.", no: "No actualiza el MCP." },
177
+ sugerencia_crear: { que: "Registra una sugerencia/mejora/bug del usuario (queda para el equipo).", uso: "mensaje (obligatorio); categoria opcional (funcionalidad|mejora|bug|otro). Se adjunta tu usuario/rol/nodo solo.", ej: "sugerencia_crear mensaje:'Sumar recordatorio de colecta' categoria:mejora.", no: "No abre tickets externos ni notifica por mail." },
178
+
179
+ mis_envios: { que: "Tus envíos/paquetes (solo los tuyos).", uso: "estado opcional (filtra por estado).", ej: "mis_envios estado:'En camino' → los que están en reparto.", no: "No muestra envíos de otros vendedores; no los edita." },
180
+ envio_consultar: { que: "Busca UN envío por tracking/código y devuelve estado, historial, destino y datos de cobro/cambio.", uso: "codigo (tracking o código). Vendedor: entre SUS envíos; staff: dentro de su nodo. Si hay varios matches, pasá el tracking completo.", ej: "El vendedor pregunta '¿dónde está 44000...?' → envio_consultar codigo:'44000123'.", no: "No cambia el estado del envío; no busca fuera de tu alcance." },
181
+ mis_sucursales: { que: "Tus sucursales / puntos de retiro (la principal = tu dirección de retiro), con horario de corte y ventanas.", uso: "Sin parámetros.", ej: "mis_sucursales → confirmás desde dónde te colectan.", no: "No las edita (para eso, sucursal_guardar)." },
182
+ sucursal_guardar: { que: "Crea o edita una sucursal tuya (punto de retiro). Geocodifica la dirección sola.", uso: "id vacío = nueva; nombre obligatorio; principal:true la vuelve tu dirección de retiro; horarioCorte HH:MM y ventanas opcionales.", ej: "Cambiar tu retiro: sucursal_guardar id:<principal> direccion:'Av. Rivadavia 5000, CABA' principal:true.", no: "No mueve dinero; el detalle de piso/depto/timbre va aparte, no al geocoder." },
183
+ mi_colecta: { que: "Estado de tu colecta (retiro): si la automática está prendida, si pediste una por única vez y el aviso de costo.", uso: "Sin parámetros.", ej: "mi_colecta → ver si mañana te pasan a buscar.", no: "No la prende/apaga (colecta_auto) ni la solicita (colecta_solicitar)." },
184
+ colecta_solicitar: { que: "Pedís que te retiren los envíos POR ÚNICA VEZ, aunque tengas la automática apagada.", uso: "Sin parámetros. Devuelve el aviso de costo si aplica.", ej: "colecta_solicitar → aparecés en el panel de colecta del nodo.", no: "No prende la colecta automática permanente; no mueve dinero." },
185
+ colecta_auto: { que: "Prende (true) o apaga (false) tu colecta AUTOMÁTICA.", uso: "activa (bool).", ej: "colecta_auto activa:false → dejás de que te retiren; llevás vos al depósito.", no: "No agenda una colecta puntual (para eso colecta_solicitar)." },
186
+
187
+ mi_stock: { que: "Tu stock físico (solo tus productos).", uso: "Sin parámetros.", ej: "mi_stock → ver unidades por SKU.", no: "No muestra disponible-para-vender (usá mi_disponible)." },
188
+ mi_disponible: { que: "Disponible por SKU = físico − comprometido; marca ⚠️ si está bajo el mínimo.", uso: "Sin parámetros.", ej: "mi_disponible → cuántas unidades podés seguir vendiendo sin sobrevender.", no: "No repone stock." },
189
+ mi_rentabilidad: { que: "Margen por SKU (precio − flete real − COGS).", uso: "desde/hasta YYYY-MM-DD (default: mes en curso).", ej: "mi_rentabilidad desde:2026-08-01 → margen del mes.", no: "No incluye gastos fijos del negocio; es por SKU." },
190
+ mis_productos: { que: "Tu catálogo de productos.", uso: "Sin parámetros.", ej: "mis_productos → ver SKUs cargados.", no: "No da de alta (producto_crear, si el nodo tiene WMS)." },
191
+ mis_top_productos: { que: "Ranking de tus productos más despachados.", uso: "desde/hasta YYYY-MM-DD (default: mes).", ej: "mis_top_productos → qué se vende más.", no: "No muestra rentabilidad (usá mi_rentabilidad)." },
192
+ mis_kpis: { que: "Tus métricas + benchmark anónimo de tu nodo.", uso: "desde/hasta opcionales.", ej: "mis_kpis → tu tasa de entrega vs. el promedio del nodo.", no: "No revela datos de otros vendedores (el benchmark es anónimo)." },
193
+
194
+ mi_ruta: { que: "Tus entregas asignadas: las paradas de tu ruta del día.", uso: "Sin parámetros.", ej: "mi_ruta → orden de reparto de hoy.", no: "No marca entregas (eso se hace escaneando en la app)." },
195
+ mis_colectas: { que: "Las colectas/retiros que tenés asignados.", uso: "Sin parámetros.", ej: "mis_colectas → a qué vendedores tenés que ir a buscar.", no: "No las completa/confirma desde acá." },
196
+
197
+ envio_cargar: { que: "Registra un envío nuevo (queda 'A retirar') y devuelve tracking + link de etiqueta + link para subir foto.", uso: "Obligatorios: destinatario, telefono, direccion, localidad. Staff pasa `cliente` (nombre del vendedor de su nodo); el vendedor no. montoCobro = cobro contra entrega (opcional).", ej: "Staff: envio_cargar cliente:'Distri Sur' destinatario:'Ana' telefono:'11...' direccion:'Belgrano 100' localidad:'Lanús' montoCobro:15000.", no: "No mueve dinero (montoCobro es el cobro a destino, no un asiento); no imprime, devuelve el link." },
198
+ envio_desde_etiqueta_ml: { que: "Registra un envío a partir de lo que VOS (Claude) leíste de la foto de una etiqueta de Mercado Libre.", uso: "Leé la etiqueta: mlShipmentId + (si podés) mlQr crudo, mlSenderId del vendedor y el destino. El vendedor se mapea por mlSenderId (cuenta vinculada) o `cliente` por nombre. Una llamada por etiqueta.", ej: "envio_desde_etiqueta_ml mlSenderId:'123456' mlShipmentId:'44000...' destinatario:'Juan' direccion:'...' localidad:'Avellaneda'.", no: "No mueve dinero; si no reconoce el vendedor por mlSenderId tenés que pasar `cliente`." },
199
+ envio_reasignar_cliente: { que: "Mueve UN envío (por tracking) a otro cliente/vendedor de tu nodo cuando se cargó mal.", uso: "tracking + cliente (nombre o id destino, de tu nodo). Queda en el historial.", ej: "envio_reasignar_cliente tracking:'44000...' cliente:'Comercial Norte'.", no: "No cruza nodos; no cambia el estado del envío." },
200
+ cobro_corregir: { que: "Ajusta el monto a cobrar contra entrega de un envío (se cobró de más/de menos), guardando el original en el historial.", uso: "envioId + monto nuevo; motivo opcional.", ej: "cobro_corregir envioId:987 monto:12000 motivo:'lista vieja'.", no: "No mueve dinero en cuentas: corrige el DATO del envío." },
201
+
202
+ colecta_ver: { que: "Resumen de valores de colecta del nodo: pago default, cobros por cliente y pagos pactados por cliente+mensajero.", uso: "Sin parámetros.", ej: "colecta_ver → cuánto se paga/cobra por colecta.", no: "No cambia tarifas (colecta_configurar)." },
203
+ colecta_pendientes: { que: "Panel de colectas del nodo: `items` = a retirar HOY (por cliente, con corte y mensajero asignado); `itemsManana` = clientes cuyos envíos entraron después del corte → van a mañana.", uso: "Sin parámetros. Trae el colectaId para desasignar.", ej: "'¿Quién levanta a Distri Sur?' → colecta_pendientes y mirás items.", no: "No asigna (colecta_asignar) ni configura tarifas." },
204
+ colecta_configurar: { que: "Setea un valor de colecta según alcance.", uso: "alcance: 'nodo' (pago default por colecta), 'mensajero' (default de ese cadete), 'cliente' (cuánto se le COBRA a ese vendedor; idCliente + valor, minEnvios opcional), 'par' (pago pactado a un mensajero por un cliente puntual; idCliente + mensajero + valor). Scopeado a tu nodo.", ej: "colecta_configurar alcance:cliente idCliente:'130' valor:2500 minEnvios:5 → gratis desde 5 envíos, sino $2500.", no: "No mueve dinero: es config de tarifa (el cobro se aplica al liquidar)." },
205
+ colecta_asignar: { que: "Asigna la colecta de un cliente a un mensajero (ambos por nombre de tu nodo) y avisa al cadete.", uso: "cliente + mensajero (nombres). Si el corte venció, la programa para el próximo día hábil.", ej: "colecta_asignar cliente:'Distri Sur' mensajero:'Maxi'.", no: "No mueve dinero; no cruza nodos." },
206
+ colecta_desasignar: { que: "Quita la asignación de una colecta (los envíos vuelven a 'sin colecta').", uso: "colectaId (lo devuelve colecta_pendientes).", ej: "colecta_desasignar colectaId:44.", no: "No borra los envíos; no mueve dinero." },
207
+ zonas_reparto: { que: "Zonas de reparto del nodo con sus metazonas y qué mensajeros tiene cada una.", uso: "Sin parámetros.", ej: "zonas_reparto → ver quién cubre Palermo.", no: "No asigna (asignar_mensajero_zona / asignar_nodo_zona)." },
208
+ asignar_mensajero_zona: { que: "Asigna un mensajero de tu nodo a una metazona; si la zona existe le suma el cadete, si no la crea.", uso: "mensajero + metazona; nombre opcional.", ej: "asignar_mensajero_zona mensajero:'Maxi' metazona:'Palermo'.", no: "No cruza nodos." },
209
+ asignar_nodo_zona: { que: "Asigna un NODO COMPLETO (de tu grupo logístico) a una metazona, cuando ese nodo cubre toda la localidad.", uso: "nodo (nombre, debe compartir grupo logístico) + metazona; nombre opcional.", ej: "asignar_nodo_zona nodo:'RL' metazona:'Portela'.", no: "No suma nodos fuera de tu grupo logístico." },
210
+ envios_por_zona: { que: "Envíos que OTROS nodos te rutearon por zona de reparto, agrupados por zona (para aceptar/rechazar).", uso: "Sin parámetros. Cada envío trae su id (para procesar_zona/rechazar_zona).", ej: "envios_por_zona → ver qué te mandaron por la zona Sur.", no: "No los acepta solo: usá procesar_zona o rechazar_zona." },
211
+ procesar_zona: { que: "ACEPTA (recibe en tu nodo) envíos ruteados por zona.", uso: "envioIds (de envios_por_zona); mensajeroId opcional para asignarlos.", ej: "procesar_zona envioIds:[101,102] mensajeroId:7.", no: "No mueve dinero (el clearing es config)." },
212
+ rechazar_zona: { que: "RECHAZA envíos ruteados por zona: dejan de aparecerte y quedan para origen u otros nodos de la zona.", uso: "envioIds (de envios_por_zona); motivo opcional.", ej: "rechazar_zona envioIds:[103] motivo:'fuera de mi cobertura'.", no: "No cambia el estado del envío." },
213
+
214
+ rendiciones: { que: "Estado de rendiciones: cobros/cambios/devoluciones a recuperar o rendir, con totales y quién tiene cada uno.", uso: "Sin parámetros. Scopeado a tu alcance (vendedor: lo tuyo; nodo: tu nodo; mensajero: lo suyo).", ej: "rendiciones → cuánto falta que rinda cada cadete.", no: "No confirma ni revierte (rendicion_revertir); NO incluye rutas de marketplace adjudicadas (ver ayuda tema:facturacion_marketplace)." },
215
+ rendicion_revertir: { que: "Revierte un cobro/cambio marcado como 'rendido' por error → vuelve a 'a rendir'.", uso: "envioId + tipo (cobro|cambio); motivo opcional. Deja registro.", ej: "rendicion_revertir envioId:987 tipo:cobro motivo:'se marcó sin recibir'.", no: "No mueve dinero en cuentas; corrige el estado de la rendición." },
216
+ reclamos_listar: { que: "Reclamos abiertos de clientes ligados a liquidaciones/rendiciones (tipo, estado, trackings en disputa).", uso: "estado opcional (abierto|resuelto). Vendedor: lo suyo; operador: su nodo.", ej: "reclamos_listar estado:abierto → qué cobros están en disputa.", no: "No resuelve reclamos desde acá." },
217
+
218
+ rutas_publicas: { que: "Publicaciones ABIERTAS de toda la red que tu nodo puede tomar (con cuántas ofertas tiene cada una). Marca las tuyas con esMia.", uso: "Sin parámetros.", ej: "rutas_publicas → ver qué rutas hay para ofertar.", no: "No oferta (ruta_ofertar)." },
219
+ mis_rutas: { que: "Tus publicaciones (con estado/adjudicación: nodoToma y precioAcordado) y las ofertas que hiciste a otros.", uso: "Sin parámetros.", ej: "mis_rutas → ver si tu ruta se adjudicó y a quién.", no: "El precioAcordado es la obligación registrada, NO un asiento de facturación (ver ayuda tema:facturacion_marketplace)." },
220
+ ruta_ofertas: { que: "Ofertas recibidas en una publicación TUYA, de la más barata a la más cara. Solo el que publicó.", uso: "publicacionId. Los ids de oferta sirven para adjudicar.", ej: "ruta_ofertas publicacionId:12 → elegís la mejor y su ofertaId.", no: "No adjudica (ruta_adjudicar)." },
221
+ ruta_publicar: { que: "Publicás una ruta/colecta/viaje para que cualquier nodo la tome (subasta abierta). VOS le pagás al que la toma.", uso: "titulo (obligatorio); tipo (ruta|colecta|viaje), descripcion, zona, precioMax (tope que ofrecés pagar) opcionales.", ej: "ruta_publicar titulo:'Reparto zona Oeste 40 paquetes' tipo:ruta precioMax:20000.", no: "No mueve dinero: la obligación recién se registra al adjudicar; el pago es manual." },
222
+ ruta_ofertar: { que: "Ofertás por una publicación de OTRO nodo. precio = lo que cobrás por hacerla (más barato = mejor para el que publica).", uso: "publicacionId + precio; nota opcional. Si ya ofertaste, la actualiza.", ej: "ruta_ofertar publicacionId:12 precio:18000 nota:'salgo 8am'.", no: "No podés ofertar en tu propia publicación." },
223
+ ruta_adjudicar: { que: "Elegís la oferta ganadora de una publicación TUYA y cerrás la subasta: el nodo ganador la toma a su precio y queda registrada la obligación.", uso: "publicacionId + ofertaId (de ruta_ofertas).", ej: "ruta_adjudicar publicacionId:12 ofertaId:34 → gana el nodo de esa oferta a $18000.", no: "NO crea asiento de facturación/rendición/comisión ni clearing automático: el pago entre nodos se salda MANUALMENTE (ver ayuda tema:facturacion_marketplace)." },
224
+
225
+ afiliacion_listar: { que: "Afiliaciones (afiliado↔entidad referida) con su comisión por envío, vigencia y estado.", uso: "afiliado y/o entidad opcionales para filtrar. Scopeado a tu nodo.", ej: "afiliacion_listar afiliado:'Nodo RL'.", no: "No muestra comisiones devengadas (comisiones_afiliado_ver)." },
226
+ comisiones_afiliado_ver: { que: "Comisiones devengadas por envío (pendiente/liquidada) con totales.", uso: "afiliado, desde, hasta (YYYY-MM-DD) opcionales. Scopeado a tu nodo.", ej: "comisiones_afiliado_ver afiliado:'Nodo RL' desde:2026-08-01.", no: "No las paga (comision_liquidar); NO incluye rutas de marketplace." },
227
+ afiliado_crear: { que: "Alta de un afiliado (quien trae volumen nuevo a la red).", uso: "nombre + tipo (nodo|mensajero|externo); refId = id del nodo/mensajero (null si externo).", ej: "afiliado_crear nombre:'Juan Ref' tipo:externo.", no: "No crea la afiliación/comisión (afiliacion_crear); no toca dinero." },
228
+ afiliacion_crear: { que: "Vincula un afiliado con una entidad referida (nodo o cliente) y su comisión RECURRENTE por envío.", uso: "afiliado + entidadTipo (nodo|cliente) + entidad + tipoComision (porcentaje|montoFijo) + valorComision; fechaExpiracion opcional. Una entidad = un afiliado activo.", ej: "afiliacion_crear afiliado:'Juan Ref' entidadTipo:cliente entidad:'Distri Sur' tipoComision:montoFijo valorComision:50.", no: "No paga (comision_liquidar); es config, no mueve dinero." },
229
+ afiliacion_editar: { que: "Edita una afiliación: valorComision, fechaExpiracion (renovar/extender) y/o activa.", uso: "id + los campos a cambiar.", ej: "afiliacion_editar id:5 activa:false → la das de baja.", no: "No toca dinero." },
230
+ comision_liquidar: { que: "Marca comisiones devengadas como LIQUIDADAS (registra el pago al afiliado).", uso: "ids = lista de comisiones (de comisiones_afiliado_ver). Requiere permiso de finanzas. Scopeado a tu nodo.", ej: "comision_liquidar ids:[10,11,12].", no: "No calcula comisiones (se devengan solas por envío); no factura." },
231
+
232
+ kpi_nodo: { que: "Tablero del nodo: P&L, top clientes, caídas.", uso: "desde/hasta opcionales; nodo solo para admin.", ej: "kpi_nodo desde:2026-08-01 → resultado del mes.", no: "No baja a un vendedor puntual; solo tu nodo (salvo admin)." },
233
+ kpi_red: { que: "KPIs globales de la red.", uso: "Sin parámetros. Solo admin global.", ej: "kpi_red → panorama SaaS de toda la red.", no: "No desglosa por nodo (usá kpi_nodo con nodo)." },
234
+ metricas_por_tipo: { que: "Métricas por TIPO de envío (flex/tienda/manual): total, entregados, tasa y % entregado antes de las 21hs. Para admin desglosa por logística.", uso: "desde/hasta YYYY-MM-DD (default 30 días); nodo solo admin.", ej: "metricas_por_tipo → ver si los Flex se entregan a tiempo.", no: "No es por cliente." },
235
+ top_productos: { que: "Ranking de productos más despachados del nodo (WMS).", uso: "desde/hasta y cliente opcionales.", ej: "top_productos cliente:'Distri Sur'.", no: "Requiere WMS activo en el nodo." },
236
+
237
+ stock_nodo: { que: "Stock del depósito del nodo (WMS).", uso: "cliente opcional para filtrar.", ej: "stock_nodo cliente:'Distri Sur'.", no: "Requiere WMS activo; no repone." },
238
+ productos_nodo: { que: "Catálogo del depósito del nodo (WMS).", uso: "cliente opcional.", ej: "productos_nodo.", no: "Requiere WMS activo." },
239
+ producto_crear: { que: "Alta de producto en el depósito (WMS).", uso: "nombre + sku/codigoBarra/peso/volumen opcionales; staff pasa idCliente dueño.", ej: "producto_crear nombre:'Remera M' sku:'REM-M' idCliente:'130'.", no: "No mueve dinero; requiere WMS activo." },
240
+
241
+ clientes_del_nodo: { que: "Clientes/vendedores del nodo con su lista de precio.", uso: "Sin parámetros. Scopeado a tu nodo.", ej: "clientes_del_nodo → ver a quién facturás y con qué lista.", no: "No los crea/edita (cliente_crear/editar)." },
242
+ cliente_crear: { que: "Alta de cliente/vendedor en tu nodo.", uso: "nombre obligatorio; telefono/dni/direccion/idLista opcionales.", ej: "cliente_crear nombre:'Nueva Distri' idLista:'ID12'.", no: "No toca dinero; no cruza nodos." },
243
+ cliente_editar: { que: "Edita un cliente de tu nodo (p. ej. cambiar su lista de precio).", uso: "id + nombre (obligatorio) + los campos a cambiar (idLista, etc.).", ej: "cliente_editar id:130 nombre:'Distri Sur' idLista:'ID12'.", no: "No toca dinero." },
244
+ precios_ver: { que: "Listas de precios por zona de tu nodo.", uso: "Sin parámetros. Requiere permiso 'precios'.", ej: "precios_ver → ver tarifas por zona.", no: "No las edita (precio_actualizar)." },
245
+ precio_actualizar: { que: "Cambia precios de una lista creando una VERSIÓN nueva (histórico intacto).", uso: "idLista + los tramos (cercana/media/lejana/muyLejana); referencia/vigenciaDesde opcionales. Requiere permiso 'precios' (y que el conector lo permita).", ej: "precio_actualizar idLista:'ID12' cercana:1500 media:2000 lejana:2800.", no: "No mueve dinero; puede estar deshabilitado por el conector (ALLOW_PRECIOS)." },
246
+ cuentas_vinculadas: { que: "Cuántas cuentas de tienda hay vinculadas, por proveedor (ML / TiendaNube / TiendaNegocio).", uso: "nodo opcional (solo admin, baja al desglose por cliente).", ej: "cuentas_vinculadas → cuántos vendedores tienen ML conectado.", no: "No vincula (generar_enlace_vinculacion)." },
247
+ generar_enlace_vinculacion: { que: "Genera el enlace para vincular una tienda; se lo mandás al cliente para que autorice.", uso: "proveedor (ml|tiendanube|tiendanegocio); staff pasa `cliente` (de su nodo).", ej: "generar_enlace_vinculacion proveedor:ml cliente:'Distri Sur'.", no: "No completa la vinculación (la autoriza el dueño de la tienda); no toca dinero." },
248
+
249
+ chofer_listar: { que: "Usuarios de tu nodo, incluidos los choferes (rol mensajero): id, nombre, teléfono, activo.", uso: "Sin parámetros. Requiere permiso 'usuarios'.", ej: "chofer_listar → ver tus cadetes.", no: "No crea/edita (chofer_crear/editar)." },
250
+ chofer_crear: { que: "Da de alta un chofer (mensajero) en tu nodo con una CLAVE TEMPORAL que devuelve el server.", uso: "nombre + telefono + email; mensajeroNombre opcional (macheo con su cta cte). Pasale la clave; la cambia al primer ingreso.", ej: "chofer_crear nombre:'Maxi' telefono:'11...' email:'maxi@...'.", no: "Claude nunca inventa la clave; el server la genera." },
251
+ chofer_editar: { que: "Edita un chofer de tu nodo: nombre, teléfono, mensajeroNombre y/o activo.", uso: "id + los campos a cambiar.", ej: "chofer_editar id:7 activo:false → lo desactivás.", no: "No toca credenciales/clave." },
252
+ usuario_habilitar_reparto: { que: "Marca a un operador/admin de tu nodo como TAMBIÉN mensajero (puede escanear, autoasignarse y entregar).", uso: "id + activo (default true).", ej: "usuario_habilitar_reparto id:4 → ese comisionista ya puede repartir.", no: "No toca credenciales." },
253
+
254
+ nodos_listar: { que: "Todas las logísticas (nodos) de la red.", uso: "Sin parámetros. Solo admin global.", ej: "nodos_listar → id/nombre de cada nodo.", no: "No los crea (nodo_crear)." },
255
+ nodo_crear: { que: "Alta de una logística/nodo.", uso: "nombre + telefono opcional. Solo admin global.", ej: "nodo_crear nombre:'Nodo Oeste' telefono:'11...'.", no: "No toca dinero." },
256
+ sugerencias_listar: { que: "Sugerencias entrantes de usuarios (funciones/mejoras/bugs) con usuario, rol, nodo, estado.", uso: "rol y/o estado opcionales. Operador: su nodo; admin: todas.", ej: "sugerencias_listar estado:nueva.", no: "No las implementa; es lectura." },
257
+ };
258
+
259
+ // Renderiza la ayuda. q = nombre de tool o clave de tema (opcional).
260
+ // ctx = { version, disponibles: Set<string> } — disponibles filtra el índice a lo
261
+ // que ese usuario realmente tiene (según su rol/permisos).
262
+ export function renderAyuda(q, ctx = {}) {
263
+ const version = ctx.version ?? "";
264
+ const disponibles = ctx.disponibles instanceof Set ? ctx.disponibles : null;
265
+ const clave = String(q ?? "").trim().toLowerCase().replace(/[^a-z_]/g, "");
266
+
267
+ if (!clave) return indice(version, disponibles);
268
+
269
+ if (TOPICS[clave]) return TOPICS[clave] + `\n\n(Consultá una herramienta con: ayuda tool:<nombre>)`;
270
+
271
+ const d = TOOL_DOCS[clave];
272
+ if (d) {
273
+ const tiene = !disponibles || disponibles.has(clave);
274
+ const nota = tiene ? "" : "\n\n(⚠️ Con tu rol/permiso actual esta herramienta no está disponible.)";
275
+ return [
276
+ `🔧 ${clave}`,
277
+ "",
278
+ `Qué hace: ${d.que}`,
279
+ `Cómo usar: ${d.uso}`,
280
+ `Ejemplo: ${d.ej}`,
281
+ `Qué NO hace: ${d.no}`,
282
+ ].join("\n") + nota;
283
+ }
284
+
285
+ // No matcheó: sugerir por prefijo/substring.
286
+ const cerca = Object.keys(TOOL_DOCS).filter((n) => n.includes(clave)).slice(0, 8);
287
+ const temas = Object.keys(TOPICS);
288
+ return [
289
+ `No encontré una herramienta o tema llamado "${clave}".`,
290
+ cerca.length ? `¿Quisiste decir?: ${cerca.join(", ")}` : "",
291
+ `Temas disponibles: ${temas.join(", ")}`,
292
+ `Sin argumentos, ayuda te da el índice completo.`,
293
+ ].filter(Boolean).join("\n");
294
+ }
295
+
296
+ function indice(version, disponibles) {
297
+ const out = [
298
+ `AYUDA DEL MCP NEXUS FLEX${version ? ` (v${version})` : ""}`,
299
+ "",
300
+ "Pedí el detalle de una herramienta con: ayuda tool:<nombre>",
301
+ "Pedí un tema transversal con: ayuda tema:<clave>",
302
+ `Temas: ${Object.keys(TOPICS).join(", ")}`,
303
+ "",
304
+ "Herramientas" + (disponibles ? " disponibles para vos" : "") + ":",
305
+ ];
306
+ for (const g of GROUPS) {
307
+ const items = g.tools.filter((t) => TOOL_DOCS[t] && (!disponibles || disponibles.has(t)));
308
+ if (!items.length) continue;
309
+ out.push("", `▸ ${g.titulo}`);
310
+ for (const t of items) out.push(` • ${t} — ${TOOL_DOCS[t].que}`);
311
+ }
312
+ // Tools registradas que no estén en ningún grupo (por si se agrega una y se olvida).
313
+ if (disponibles) {
314
+ const enGrupos = new Set(GROUPS.flatMap((g) => g.tools));
315
+ const sueltas = [...disponibles].filter((t) => TOOL_DOCS[t] && !enGrupos.has(t));
316
+ if (sueltas.length) {
317
+ out.push("", "▸ Otras");
318
+ for (const t of sueltas) out.push(` • ${t} — ${TOOL_DOCS[t].que}`);
319
+ }
320
+ }
321
+ return out.join("\n");
322
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexusflex-mcp",
3
- "version": "3.1.0",
3
+ "version": "3.6.0",
4
4
  "description": "MCP de Nexus Flex: operá tu nodo/cuenta desde un asistente de IA (altas de clientes, stock, KPIs). Login por autorización web (device-flow). NO toca dinero.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,11 +11,30 @@
11
11
  "url": "git+https://github.com/ezequieldos/nexus-flex.git",
12
12
  "directory": "mcp"
13
13
  },
14
- "bugs": { "url": "https://github.com/ezequieldos/nexus-flex/issues" },
15
- "keywords": ["mcp", "modelcontextprotocol", "nexusflex", "logistica", "claude"],
16
- "engines": { "node": ">=20" },
17
- "bin": { "nexusflex-mcp": "server.mjs" },
18
- "files": ["server.mjs", "api.mjs", "device-auth.mjs", "README.md", "INSTALAR.md"],
14
+ "bugs": {
15
+ "url": "https://github.com/ezequieldos/nexus-flex/issues"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "modelcontextprotocol",
20
+ "nexusflex",
21
+ "logistica",
22
+ "claude"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "bin": {
28
+ "nexusflex-mcp": "server.mjs"
29
+ },
30
+ "files": [
31
+ "server.mjs",
32
+ "api.mjs",
33
+ "device-auth.mjs",
34
+ "docs.mjs",
35
+ "README.md",
36
+ "INSTALAR.md"
37
+ ],
19
38
  "scripts": {
20
39
  "start": "node server.mjs",
21
40
  "login": "node server.mjs login",
@@ -25,5 +44,7 @@
25
44
  "@modelcontextprotocol/sdk": "^1.30.0",
26
45
  "zod": "^3.24.1"
27
46
  },
28
- "publishConfig": { "access": "public" }
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
29
50
  }
package/server.mjs CHANGED
@@ -25,6 +25,7 @@
25
25
  // ============================================================================
26
26
  import { runDeviceFlow, saveToken, clearToken, tokenFilePath } from "./device-auth.mjs";
27
27
  import { api, log, API_URL } from "./api.mjs";
28
+ import { renderAyuda, guiaOnboarding } from "./docs.mjs";
28
29
 
29
30
  // --- Subcomandos de línea de comando (login/logout) antes de arrancar el server ---
30
31
  const cmd = process.argv[2];
@@ -53,7 +54,15 @@ const truthy = (v) => /^(1|true|yes|si|sí)$/i.test(v ?? "");
53
54
  const ALLOW_WRITE = truthy(process.env.NEXUSFLEX_MCP_ALLOW_WRITE);
54
55
  const ALLOW_PRECIOS = truthy(process.env.NEXUSFLEX_MCP_ALLOW_PRECIOS);
55
56
 
56
- const server = new McpServer({ name: "nexusflex", version: "3.1.0" });
57
+ const MCP_VERSION = "3.6.0"; // bumpear junto con package.json + backend/services/mcp-remote.ts
58
+ const NOVEDADES = [
59
+ "3.6.0 — Guía de arranque por rol (`guia` + instrucciones al conectar): metodología para empezar rápido (mensajero: foto→envío al colectar; vendedor: cargar ventas; nodo: implementar + operar + clearing). Los MENSAJEROS ya pueden registrar envíos de lo que colectan (`envio_cargar` / `envio_desde_etiqueta_ml`), scopeado a clientes de su nodo o que hayan colectado (marketplace incluido).",
60
+ "3.5.0 — Ayuda integrada (`ayuda`): documentación por herramienta (qué hace, cómo usar, ejemplo, qué NO hace) + temas transversales (facturacion_marketplace, dinero, aislamiento). Aclarado que adjudicar una ruta del marketplace NO genera facturación/rendición/comisión automática (el pago entre nodos es manual).",
61
+ "3.4.0 — Marketplace de rutas públicas (ruta_publicar/ofertar/adjudicar); admin también reparte; registrar envío desde foto de etiqueta ML; alta/edición de choferes; mcp_version.",
62
+ "3.3.0 — Afiliados y comisión por envío; métricas por tipo; gestión de colectas (asignar/rechazar entre nodos); cuentas vinculadas; sucursales; colecta a pedido; metazonas flexibles.",
63
+ ];
64
+ const INSTRUCCIONES = "MCP de Nexus Flex. Para arrancar rápido llamá al tool `guia` (metodología según tu rol: mensajero/vendedor/nodo) y `ayuda` (índice de herramientas + temas). El MCP NUNCA mueve dinero.";
65
+ const server = new McpServer({ name: "nexusflex", version: MCP_VERSION }, { instructions: INSTRUCCIONES });
57
66
 
58
67
  /** Formatea un ApiResult como respuesta de tool MCP, con mensajes claros y SIN
59
68
  * filtrar tokens ni stack traces. */
@@ -81,7 +90,8 @@ const q = (params) => {
81
90
  const s = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
82
91
  return s ? `?${s}` : "";
83
92
  };
84
- const tool = (name, def, handler) => server.registerTool(name, def, handler);
93
+ const registrados = new Set(); // para que `ayuda` liste solo lo que este usuario tiene
94
+ const tool = (name, def, handler) => { registrados.add(name); server.registerTool(name, def, handler); };
85
95
 
86
96
  // ============================================================================
87
97
  // CAPA 2 — Identidad: leemos /auth/me ANTES de registrar tools. Cada usuario ve
@@ -115,6 +125,31 @@ tool("mis_datos", {
115
125
  inputSchema: {},
116
126
  }, async () => run(() => api("GET", "/auth/me")));
117
127
 
128
+ // Versión del MCP corriendo (cualquier rol). Sirve para saber si tenés lo último.
129
+ tool("mcp_version", {
130
+ title: "Versión del MCP",
131
+ description: "Qué versión del MCP de Nexus Flex está corriendo y por qué vía. Cualquier rol lo puede consultar.",
132
+ inputSchema: {},
133
+ }, async () => ({ content: [{ type: "text", text: JSON.stringify({ version: MCP_VERSION, modo: "npx (Claude Desktop)", novedades: NOVEDADES, nota: "Para tener lo último: actualizá con «npx -y nexusflex-mcp@latest» o pasate al conector remoto (siempre al día, sin instalar/actualizar)." }) }] }));
134
+
135
+ // Guía de arranque por rol (metodología). Cualquier rol; read-only.
136
+ tool("guia", {
137
+ title: "Guía de arranque (metodología para tu rol)",
138
+ description: "Cómo empezar a usar Nexus Flex rápido según tu rol: mensajero (registrar lo que colectás sacando fotos → envío), vendedor (cargar ventas), nodo (implementar + operar + clearing). Sin argumentos = tu rol; `rol` para ver otra (mensajero|cliente|nodo).",
139
+ inputSchema: { rol: z.string().optional().describe("mensajero | cliente | nodo (default: tu rol)") },
140
+ }, async ({ rol: r }) => {
141
+ const pedido = String(r ?? "").trim().toLowerCase();
142
+ const target = pedido === "nodo" ? "operador" : pedido || rol;
143
+ return { content: [{ type: "text", text: guiaOnboarding(target) }] };
144
+ });
145
+
146
+ // Sugerencias: cualquier usuario (todos los roles) puede proponer funciones/mejoras.
147
+ tool("sugerencia_crear", {
148
+ title: "Enviar una sugerencia",
149
+ description: "Proponé una función nueva, mejora o reportá un bug. mensaje obligatorio; categoria opcional (funcionalidad|mejora|bug|otro). Se registra tu usuario/rol/nodo automáticamente.",
150
+ inputSchema: { mensaje: z.string().min(1), categoria: z.enum(["funcionalidad", "mejora", "bug", "otro"]).optional() },
151
+ }, async (args) => run(() => api("POST", "/feedback/sugerencias", args)));
152
+
118
153
  // ============================================================================
119
154
  // ROL CLIENTE (vendedor) — SOLO lo suyo. El backend lo fuerza a su idCliente.
120
155
  // ============================================================================
@@ -164,6 +199,32 @@ if (isCliente) {
164
199
  description: "Ranking de TUS productos más despachados en un rango (default: mes en curso). Solo tus productos.",
165
200
  inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
166
201
  }, async ({ desde, hasta }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta })}`)));
202
+ tool("mis_sucursales", {
203
+ title: "Mis sucursales / puntos de retiro",
204
+ description: "Tus sucursales (puntos de retiro) con dirección, horario de corte y ventanas. La sucursal principal es tu dirección de retiro. Solo lectura.",
205
+ inputSchema: {},
206
+ }, async () => run(() => api("GET", "/portal/sucursales")));
207
+ tool("mi_colecta", {
208
+ title: "Estado de mi colecta",
209
+ description: "Cómo está tu colecta (retiro): si la colecta AUTOMÁTICA está prendida, si pediste una por única vez, y el aviso de costo (si te cobran cuando llevás menos de X envíos). Solo lectura.",
210
+ inputSchema: {},
211
+ }, async () => run(() => api("GET", "/portal/colecta")));
212
+ }
213
+
214
+ // ============================================================================
215
+ // ROL MENSAJERO — su ruta y colectas (para saber cuántos envíos tendrá).
216
+ // ============================================================================
217
+ if (rol === "mensajero") {
218
+ tool("mi_ruta", {
219
+ title: "Mi ruta del día",
220
+ description: "Tus entregas asignadas (paradas de la ruta del día). Solo lectura.",
221
+ inputSchema: {},
222
+ }, async () => run(() => api("GET", "/flujo/mi-ruta")));
223
+ tool("mis_colectas", {
224
+ title: "Mis colectas (retiros)",
225
+ description: "Las colectas/retiros que tenés asignados. Solo lectura.",
226
+ inputSchema: {},
227
+ }, async () => run(() => api("GET", "/colecta/mis-colectas")));
167
228
  }
168
229
 
169
230
  // ============================================================================
@@ -248,11 +309,354 @@ if (isCliente || (isStaff && puede("gestion"))) {
248
309
  }, async ({ proveedor, cliente }) => run(() => api("GET", `/${proveedor}/auth${q({ cliente })}`)));
249
310
  }
250
311
 
312
+ // ============================================================================
313
+ // RENDICIONES (lectura) + ZONAS DE REPARTO (mensajeros por zona)
314
+ // ============================================================================
315
+ if (isCliente || isStaff || rol === "mensajero") {
316
+ tool("rendiciones", {
317
+ title: "Rendiciones (a recuperar / a rendir)",
318
+ description: "Estado de rendiciones: cobros/cambios/devoluciones pendientes de recuperar o rendir, con totales y quién tiene cada uno. Scopeado a tu alcance (vendedor: lo tuyo; nodo: tu nodo). Solo lectura.",
319
+ inputSchema: {},
320
+ }, async () => run(() => api("GET", "/flujo/rendiciones")));
321
+ }
322
+
323
+ // Reclamos de clientes ligados a liquidaciones (vendedor: lo suyo; operador: su nodo).
324
+ if (isCliente || isStaff) {
325
+ tool("reclamos_listar", {
326
+ title: "Reclamos de clientes (liquidaciones)",
327
+ description: "Reclamos abiertos de clientes ligados a liquidaciones/rendiciones: tipo, estado, trackings en disputa, detalle. Sirve para ver qué cobros/rendiciones pendientes tienen reclamo. Vendedor ve lo suyo, operador su nodo. Solo lectura.",
328
+ inputSchema: { estado: z.string().optional().describe("abierto | resuelto") },
329
+ }, async ({ estado }) => run(() => api("GET", `/feedback/reclamos${q({ estado })}`)));
330
+ }
331
+ if (isStaff) {
332
+ tool("sugerencias_listar", {
333
+ title: "Sugerencias de usuarios (admin)",
334
+ description: "Sugerencias entrantes de los usuarios (funciones nuevas/mejoras/bugs) con usuario, rol, nodo, estado. Filtrá por `rol` y/o `estado`. Operador: las de su nodo; admin global: todas. Solo lectura.",
335
+ inputSchema: { rol: z.string().optional(), estado: z.string().optional().describe("nueva | vista | en_evaluacion | implementada | descartada") },
336
+ }, async ({ rol, estado }) => run(() => api("GET", `/feedback/sugerencias${q({ rol, estado })}`)));
337
+ }
338
+
339
+ // Consultar UN envío por tracking/código (cuando un vendedor pregunta "¿dónde está mi
340
+ // envío X?"). Vendedor: solo entre SUS envíos; staff: dentro de su nodo.
341
+ if (isCliente || isStaff) {
342
+ tool("envio_consultar", {
343
+ title: "Consultar un envío",
344
+ description: "Busca UN envío por tracking o código y devuelve su estado actual, el historial de estados (con fechas), destinatario, dirección y datos de cobro/cambio si tiene. Vendedor: solo entre SUS envíos; staff: dentro de su nodo. Úsalo cuando un vendedor pregunta por un pedido puntual.",
345
+ inputSchema: { codigo: z.string().min(1).describe("Tracking o código del envío (lo que manda el vendedor)") },
346
+ }, async ({ codigo }) => {
347
+ const code = String(codigo ?? "").trim();
348
+ if (!code) return { content: [{ type: "text", text: "Pasá un tracking o código de envío." }], isError: true };
349
+ const low = code.toLowerCase();
350
+ try {
351
+ if (isStaff) {
352
+ const lista = await api("GET", `/flujo/envios${q({ q: code })}`);
353
+ const rows = Array.isArray(lista.data) ? lista.data : [];
354
+ if (!rows.length) return { content: [{ type: "text", text: `No encontré un envío "${code}" en tu nodo.` }] };
355
+ const exact = rows.filter((e) => String(e.tracking ?? "").toLowerCase() === low);
356
+ const pick = exact.length ? exact : rows;
357
+ if (pick.length > 1)
358
+ return { content: [{ type: "text", text: `Varios envíos matchean "${code}". Elegí uno (pasá el tracking completo):\n${JSON.stringify(pick.slice(0, 10).map((e) => ({ id: e.id, tracking: e.tracking, estado: e.estado, cliente: e.cliente, destinatario: e.destinatario })), null, 2)}` }] };
359
+ return toResult(await api("GET", `/flujo/envios/${pick[0].id}`));
360
+ }
361
+ const lista = await api("GET", "/portal/envios");
362
+ const rows = Array.isArray(lista.data) ? lista.data : [];
363
+ let match = rows.filter((e) => String(e.tracking ?? "").toLowerCase() === low);
364
+ if (!match.length) match = rows.filter((e) => String(e.tracking ?? "").toLowerCase().includes(low));
365
+ if (!match.length) return { content: [{ type: "text", text: `No encontré un envío "${code}" entre tus envíos.` }] };
366
+ if (match.length > 1)
367
+ return { content: [{ type: "text", text: `Varios envíos matchean "${code}". Pasá el tracking completo:\n${JSON.stringify(match.slice(0, 10).map((e) => ({ id: e.id, tracking: e.tracking, estado: e.estado })), null, 2)}` }] };
368
+ return toResult(await api("GET", `/portal/envios/${match[0].id}`));
369
+ } catch (e) {
370
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
371
+ }
372
+ });
373
+ }
374
+
375
+ // Métricas por tipo de envío (flex/tienda/manual) + % antes de 21hs + (red) por logística.
376
+ if (isCliente || isStaff) {
377
+ tool("metricas_por_tipo", {
378
+ title: "Métricas por tipo de envío",
379
+ description: "Métricas por TIPO de envío (flex/tienda/manual): total, entregados, tasa de entrega y % ENTREGADO ANTES DE LAS 21hs. Admin/red desglosa por logística (qué nodo entrega mejor). Vendedor ve lo suyo, operador su nodo. Fechas YYYY-MM-DD (default: últimos 30 días).",
380
+ inputSchema: {
381
+ desde: z.string().optional().describe("YYYY-MM-DD"),
382
+ hasta: z.string().optional().describe("YYYY-MM-DD"),
383
+ nodo: z.number().optional().describe("Solo admin/superoperador: elegir nodo (si no, la red)"),
384
+ },
385
+ }, async ({ desde, hasta, nodo }) => run(() => api("GET", `/kpi/envios-por-tipo${q({ desde, hasta, nodo })}`)));
386
+ }
387
+
388
+ // Cuentas de tienda vinculadas (ML / TiendaNube / TiendaNegocio), scopeadas por rol.
389
+ if (isCliente || isStaff) {
390
+ tool("cuentas_vinculadas", {
391
+ title: "Cuentas vinculadas (ML / TiendaNube / TiendaNegocio)",
392
+ description: "Cuántas cuentas de tienda hay vinculadas, con desglose por proveedor (Mercado Libre / TiendaNube / TiendaNegocio). Vendedor: las suyas; operador: las de los clientes de SU nodo (agrupadas por cliente); admin/superoperador: por nodo (o pasá `nodo` para bajar al desglose por cliente de ese nodo). Solo lectura.",
393
+ inputSchema: { nodo: z.number().optional().describe("Solo admin: baja al desglose por cliente de ese nodo") },
394
+ }, async ({ nodo }) => run(() => api("GET", `/cuentas-vinculadas${q({ nodo })}`)));
395
+ }
396
+
397
+ // Afiliados: comisión recurrente por envío (staff = admin global o admin de nodo, scopeado).
398
+ if (isStaff) {
399
+ tool("afiliacion_listar", {
400
+ title: "Listar afiliaciones",
401
+ description: "Afiliaciones (afiliado↔entidad referida) con su comisión por envío, vigencia y estado. Filtrá por `afiliado` o `entidad`. Solo lectura, scopeado a tu nodo.",
402
+ inputSchema: { afiliado: z.string().optional(), entidad: z.string().optional() },
403
+ }, async ({ afiliado, entidad }) => run(() => api("GET", `/afiliados/afiliaciones${q({ afiliado, entidad })}`)));
404
+ tool("comisiones_afiliado_ver", {
405
+ title: "Comisiones de afiliados",
406
+ description: "Comisiones devengadas por envío (pendiente/liquidada) con totales. Filtrá por `afiliado` y rango de fechas. Solo lectura, scopeado a tu nodo.",
407
+ inputSchema: { afiliado: z.string().optional(), desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
408
+ }, async ({ afiliado, desde, hasta }) => run(() => api("GET", `/afiliados/comisiones${q({ afiliado, desde, hasta })}`)));
409
+ }
410
+
411
+ // Choferes (mensajeros) del nodo — requiere permiso 'usuarios'.
412
+ if (isStaff && puede("usuarios")) {
413
+ tool("chofer_listar", {
414
+ title: "Listar usuarios del nodo (choferes)",
415
+ description: "Usuarios de tu nodo (incluye los choferes = rol 'mensajero') con su id, nombre, teléfono, activo. Solo lectura, scopeado a tu nodo.",
416
+ inputSchema: {},
417
+ }, async () => run(() => api("GET", "/usuarios")));
418
+ }
419
+
420
+ // Marketplace de rutas/colectas públicas (subasta abierta, cualquier nodo de la red).
421
+ if (isStaff) {
422
+ tool("rutas_publicas", {
423
+ title: "Rutas/colectas públicas (marketplace)",
424
+ description: "Publicaciones ABIERTAS de toda la red que tu nodo puede tomar (con cuántas ofertas tiene cada una). Marca las tuyas (`esMia`). Solo lectura.",
425
+ inputSchema: {},
426
+ }, async () => run(() => api("GET", "/rutas-publicas/publicas")));
427
+ tool("mis_rutas", {
428
+ title: "Mis publicaciones y ofertas (marketplace)",
429
+ description: "Tus publicaciones de rutas (con su estado/adjudicación) y las ofertas que hiciste a otros nodos. Solo lectura.",
430
+ inputSchema: {},
431
+ }, async () => run(() => api("GET", "/rutas-publicas/mias")));
432
+ tool("ruta_ofertas", {
433
+ title: "Ver ofertas de una publicación mía",
434
+ description: "Ofertas recibidas en una publicación TUYA (ordenadas por precio asc). Solo el que publicó. Usá el id de oferta para adjudicar.",
435
+ inputSchema: { publicacionId: z.number().int().positive() },
436
+ }, async ({ publicacionId }) => run(() => api("GET", `/rutas-publicas/${publicacionId}/ofertas`)));
437
+ }
438
+
439
+ if (isStaff && puede("gestion")) {
440
+ tool("zonas_reparto", {
441
+ title: "Zonas de reparto (mensajeros por zona)",
442
+ description: "Lista las zonas de reparto de tu nodo con sus metazonas y qué mensajeros tiene asignado cada una. Solo lectura.",
443
+ inputSchema: {},
444
+ }, async () => run(() => api("GET", "/zonificacion/zonas-reparto")));
445
+
446
+ tool("colecta_ver", {
447
+ title: "Ver config de colectas",
448
+ description: "Resumen de valores de colecta del nodo: pago default del nodo, cobros por cliente y pagos pactados por cliente+mensajero. Solo lectura.",
449
+ inputSchema: {},
450
+ }, async () => run(() => api("GET", "/colecta/config")));
451
+
452
+ tool("colecta_pendientes", {
453
+ title: "Colectas del día y de mañana",
454
+ description: "Panel de colectas de tu nodo. `items` = clientes a retirar HOY (con cuántos envíos, el corte y qué colecta está asignada a qué mensajero). `itemsManana` = clientes con envíos que entraron después del corte → van a la colecta de MAÑANA. Solo lectura. Sirve para «¿quién levanta a tal cliente?» y «¿el cliente X tiene envíos para mañana?».",
455
+ inputSchema: {},
456
+ }, async () => run(() => api("GET", "/colecta/panel")));
457
+
458
+ tool("envios_por_zona", {
459
+ title: "Envíos que otros nodos te rutearon (por zona)",
460
+ description: "Envíos de OTROS nodos ruteados a tu nodo por zona de reparto, agrupados por zona (para aceptarlos o rechazarlos). Cada envío trae su id (usalo en procesar_zona/rechazar_zona). Solo lectura, tu nodo.",
461
+ inputSchema: {},
462
+ }, async () => run(() => api("GET", "/colecta/zonas-a-procesar")));
463
+
464
+ tool("asignar_mensajero_zona", {
465
+ title: "Asignar un mensajero a una zona",
466
+ description: "Asigna un mensajero (por nombre, de tu nodo) a una metazona/zona (ej. «asigná a Maxi la zona de Palermo»). Si ya existe una zona con esa metazona, le suma el mensajero; si no, la crea. No cruza nodos.",
467
+ inputSchema: {
468
+ mensajero: z.string().min(1).describe("Nombre del mensajero (de tu nodo)"),
469
+ metazona: z.string().min(1).describe("Metazona/zona, ej. 'Palermo' o 'CABA'"),
470
+ nombre: z.string().optional().describe("Nombre de la zona (opcional)"),
471
+ },
472
+ }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-mensajero", args)));
473
+
474
+ tool("asignar_nodo_zona", {
475
+ title: "Asignar un NODO a una zona (grupo logístico)",
476
+ description: "Asigna un NODO COMPLETO (por nombre, de tu grupo logístico) a una metazona — para cuando ese nodo cubre toda una localidad (ej. «que RL cubra Portela»). Suma el nodo como opción de esa zona (nodoIds). El nodo debe compartir grupo logístico con el tuyo. No cruza fuera del grupo.",
477
+ inputSchema: {
478
+ nodo: z.string().min(1).describe("Nombre del nodo de tu grupo logístico que cubre la zona"),
479
+ metazona: z.string().min(1).describe("Metazona/zona, ej. 'Portela' o 'CABA'"),
480
+ nombre: z.string().optional().describe("Nombre de la zona (opcional)"),
481
+ },
482
+ }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-nodo", args)));
483
+ }
484
+
251
485
  // ============================================================================
252
486
  // ESCRITURA (opt-in por flag · NUNCA dinero). Además gateado por permiso en el backend.
253
487
  // ============================================================================
254
488
  if (ALLOW_WRITE) {
489
+ if (isCliente) {
490
+ tool("sucursal_guardar", {
491
+ title: "Crear/editar sucursal (punto de retiro)",
492
+ description: "Crea o edita una sucursal tuya (punto de retiro). Para cambiar tu DIRECCIÓN DE RETIRO editá la sucursal principal (o creá una con principal=true). id vacío = nueva sucursal. Geocodifica la dirección sola. No toca dinero.",
493
+ inputSchema: {
494
+ id: z.number().optional().describe("id de la sucursal a editar; vacío = nueva"),
495
+ nombre: z.string().min(1),
496
+ direccion: z.string().optional(),
497
+ principal: z.boolean().optional().describe("true = pasa a ser tu dirección de retiro principal"),
498
+ horarioCorte: z.string().optional().describe("HH:MM"),
499
+ ventana1Desde: z.string().optional(), ventana1Hasta: z.string().optional(),
500
+ ventana2Desde: z.string().optional(), ventana2Hasta: z.string().optional(),
501
+ },
502
+ }, async (args) => run(() => api("POST", "/portal/sucursales", args)));
503
+ tool("colecta_solicitar", {
504
+ title: "Solicitar colecta (por única vez)",
505
+ description: "Pedís que te retiren los envíos POR ÚNICA VEZ, aunque tengas la colecta automática apagada. Aparecés en el panel de colecta de tu nodo. Devuelve el aviso de costo (si te cobran cuando llevás menos de X envíos). No mueve dinero.",
506
+ inputSchema: {},
507
+ }, async () => run(() => api("POST", "/portal/colecta/solicitar")));
508
+ tool("colecta_auto", {
509
+ title: "Prender/apagar colecta automática",
510
+ description: "Activás (true) o desactivás (false) tu colecta AUTOMÁTICA. Apagada = llevás los envíos al depósito y no te retiran (salvo que pidas una por única vez con colecta_solicitar). No mueve dinero.",
511
+ inputSchema: { activa: z.boolean() },
512
+ }, async ({ activa }) => run(() => api("PUT", "/portal/colecta/auto", { activa })));
513
+ }
514
+ if (isCliente || (isStaff && puede("gestion")) || rol === "mensajero") {
515
+ tool("envio_cargar", {
516
+ title: "Cargar un envío (y etiqueta)",
517
+ description: "Registra un envío nuevo (queda 'A retirar') y devuelve el tracking + un LINK a la etiqueta imprimible + un LINK para subir una foto del envío desde el celular. El vendedor lo carga para sí mismo; el staff pasa `cliente` (nombre, de su nodo); el MENSAJERO pasa `cliente` y SOLO puede registrar clientes de su nodo o que haya colectado (marketplace incluido). Datos obligatorios: destinatario, telefono, direccion, localidad. No toca dinero (montoCobro es el cobro contra entrega, no un movimiento).",
518
+ inputSchema: {
519
+ cliente: z.string().optional().describe("Solo staff: nombre del cliente/vendedor de tu nodo (el vendedor NO lo manda)"),
520
+ destinatario: z.string().min(1).describe("Nombre de quien recibe"),
521
+ telefono: z.string().min(1),
522
+ direccion: z.string().min(1),
523
+ localidad: z.string().min(1),
524
+ cp: z.string().optional(),
525
+ montoCobro: z.number().optional().describe("Cobro contra entrega (opcional)"),
526
+ esCambio: z.boolean().optional(),
527
+ detalleCambio: z.string().optional(),
528
+ comentarios: z.string().optional(),
529
+ },
530
+ }, async (args) => run(() => api("POST", "/envios/cargar-mcp", args)));
531
+ }
532
+ if (isStaff || rol === "mensajero") {
533
+ tool("envio_desde_etiqueta_ml", {
534
+ title: "Registrar envío desde una etiqueta de Mercado Libre (foto)",
535
+ description: "Registra un envío a partir de los datos que VOS (Claude) leíste de la foto de una etiqueta de Mercado Libre. Leé la etiqueta: sacá el CÓDIGO/QR (mlShipmentId y, si podés, el contenido crudo del QR en mlQr), el vendedor (mlSenderId si figura) y el destino (destinatario, dirección, localidad, CP). El vendedor se mapea por `mlSenderId` (cuenta ML vinculada) o pasás `cliente` por nombre; staff = de tu nodo o grupo; MENSAJERO = solo clientes de tu nodo o que hayas colectado (marketplace incluido). Crea el envío ('A retirar') y devuelve tracking + link de etiqueta PROVISORIA (con el QR de ML si mandaste mlQr, reimprimible). Si NO hay etiqueta pero tenés los datos del paquete, usá `envio_cargar`. Podés pasar varias etiquetas llamando el tool una vez por cada una. No mueve dinero.",
536
+ inputSchema: {
537
+ cliente: z.string().optional().describe("Vendedor por nombre/id (si no mandás mlSenderId)"),
538
+ mlSenderId: z.string().optional().describe("sender_id del vendedor en ML (mapea a su cuenta vinculada)"),
539
+ mlShipmentId: z.string().optional().describe("id de envío/tracking de ML leído de la etiqueta"),
540
+ mlQr: z.string().optional().describe("Contenido CRUDO del QR de ML (para reimprimir la etiqueta idéntica)"),
541
+ destinatario: z.string().optional(), telefono: z.string().optional(),
542
+ direccion: z.string().optional(), localidad: z.string().optional(), cp: z.string().optional(), zona: z.string().optional(), barrio: z.string().optional(),
543
+ montoCobro: z.number().optional(), mensajero: z.string().optional().describe("Mensajero que hizo el paquete (de tu nodo)"),
544
+ },
545
+ }, async (args) => run(() => api("POST", "/envios/desde-etiqueta", args)));
546
+ tool("afiliado_crear", {
547
+ title: "Crear afiliado",
548
+ description: "Alta de un afiliado (quien trae volumen nuevo a la red). tipo: nodo|mensajero|externo; refId = id del nodo/mensajero (null si externo). Admin global o admin de nodo (scopeado a tu nodo). NO toca dinero.",
549
+ inputSchema: { nombre: z.string().min(1), tipo: z.enum(["nodo", "mensajero", "externo"]), refId: z.number().int().optional(), telefono: z.string().optional(), email: z.string().optional() },
550
+ }, async (args) => run(() => api("POST", "/afiliados", args)));
551
+ tool("afiliacion_crear", {
552
+ title: "Crear afiliación (comisión por envío)",
553
+ description: "Vincula un afiliado con una entidad referida (nodo o cliente) y su comisión RECURRENTE por envío. tipoComision: porcentaje|montoFijo. `porcentaje` se calcula sobre el valorDeclarado del envío (MVP); `montoFijo` = monto por envío. fechaExpiracion opcional (null = no vence). Una entidad = un solo afiliado activo. NO toca dinero (es config).",
554
+ inputSchema: { afiliado: z.string().min(1).describe("nombre o id"), entidadTipo: z.enum(["nodo", "cliente"]), entidad: z.string().min(1).describe("nombre o id del nodo/cliente referido"), tipoComision: z.enum(["porcentaje", "montoFijo"]), valorComision: z.number(), fechaExpiracion: z.string().optional().describe("YYYY-MM-DD") },
555
+ }, async (args) => run(() => api("POST", "/afiliados/afiliaciones", args)));
556
+ tool("afiliacion_editar", {
557
+ title: "Editar afiliación",
558
+ description: "Edita una afiliación: valorComision, fechaExpiracion (renovar/extender) y/o activa (des/reactivar). NO toca dinero.",
559
+ inputSchema: { id: z.number().int().positive(), valorComision: z.number().optional(), fechaExpiracion: z.string().optional().describe("YYYY-MM-DD; vacío = quitar vencimiento"), activa: z.boolean().optional() },
560
+ }, async ({ id, ...body }) => run(() => api("PUT", `/afiliados/afiliaciones/${id}`, body)));
561
+ if (esGlobal || esAdminNodo || permisos.includes("finanzas")) { // igual que el gate del backend
562
+ tool("comision_liquidar", {
563
+ title: "Liquidar comisiones de afiliado (registra pago)",
564
+ description: "Marca comisiones devengadas como LIQUIDADAS (registra el pago al afiliado). `ids` = lista de comisiones (de comisiones_afiliado_ver). Requiere permiso de finanzas. Scopeado a tu nodo.",
565
+ inputSchema: { ids: z.array(z.number().int().positive()).min(1) },
566
+ }, async ({ ids }) => run(() => api("POST", "/afiliados/comisiones/liquidar", { ids })));
567
+ }
568
+ }
569
+ if (puede("usuarios")) {
570
+ tool("chofer_crear", {
571
+ title: "Alta de chofer (mensajero) con clave temporal",
572
+ description: "Da de alta un chofer (rol mensajero) en TU nodo. El server genera una CLAVE TEMPORAL y la devuelve (pasásela al chofer; debe cambiarla al primer ingreso). Requiere nombre, teléfono y email.",
573
+ inputSchema: { nombre: z.string().min(1), telefono: z.string().min(1), email: z.string().min(3), mensajeroNombre: z.string().optional().describe("Nombre para el macheo con su cta cte (default: el nombre)") },
574
+ }, async (args) => run(() => api("POST", "/usuarios/chofer", args)));
575
+ tool("chofer_editar", {
576
+ title: "Editar un chofer",
577
+ description: "Edita un chofer (mensajero) de TU nodo: nombre, teléfono, mensajeroNombre y/o activo (desactivar/activar). No toca credenciales.",
578
+ inputSchema: { id: z.number().int().positive(), nombre: z.string().optional(), telefono: z.string().optional(), mensajeroNombre: z.string().optional(), activo: z.boolean().optional() },
579
+ }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/chofer/${id}`, body)));
580
+ tool("usuario_habilitar_reparto", {
581
+ title: "Habilitar a un usuario como también mensajero",
582
+ description: "Marca (o desmarca con activo=false) a un operador/admin de TU nodo como TAMBIÉN mensajero: puede escanear, autoasignarse y entregar además de administrar. No toca credenciales.",
583
+ inputSchema: { id: z.number().int().positive(), activo: z.boolean().optional().describe("default true") },
584
+ }, async ({ id, activo }) => run(() => api("PUT", `/usuarios/${id}/reparto`, { activo: activo ?? true })));
585
+ }
586
+ if (isStaff) {
587
+ tool("ruta_publicar", {
588
+ title: "Publicar una ruta/colecta en el marketplace",
589
+ description: "Publicá una ruta/colecta/viaje para que CUALQUIER nodo de la red la tome (subasta abierta). VOS le pagás al que la toma. precioMax opcional (tope que ofrecés pagar). No mueve dinero (la obligación se registra al adjudicar).",
590
+ inputSchema: { titulo: z.string().min(1), tipo: z.enum(["ruta", "colecta", "viaje"]).optional(), descripcion: z.string().optional(), zona: z.string().optional(), precioMax: z.number().optional() },
591
+ }, async (args) => run(() => api("POST", "/rutas-publicas", args)));
592
+ tool("ruta_ofertar", {
593
+ title: "Ofertar por una ruta pública",
594
+ description: "Ofertá por una publicación de OTRO nodo: `precio` = lo que cobrás por hacerla (subasta: más barato es mejor para el que publica). Si ya ofertaste, actualiza tu oferta. No podés ofertar en la tuya.",
595
+ inputSchema: { publicacionId: z.number().int().positive(), precio: z.number(), nota: z.string().optional() },
596
+ }, async (args) => run(() => api("POST", `/rutas-publicas/${args.publicacionId}/ofertar`, { precio: args.precio, nota: args.nota })));
597
+ tool("ruta_adjudicar", {
598
+ title: "Adjudicar una ruta pública (elegir ganador)",
599
+ description: "Elegí la oferta ganadora de una publicación TUYA (los ids salen de «ruta_ofertas»). Cierra la subasta: el nodo ganador la toma al precio de su oferta (queda registrada la obligación). NO genera facturación/rendición/comisión ni clearing automático: el pago entre nodos se salda MANUALMENTE (ver «ayuda» tema:facturacion_marketplace).",
600
+ inputSchema: { publicacionId: z.number().int().positive(), ofertaId: z.number().int().positive() },
601
+ }, async ({ publicacionId, ofertaId }) => run(() => api("POST", `/rutas-publicas/${publicacionId}/adjudicar`, { ofertaId })));
602
+ }
603
+ if (isStaff) {
604
+ tool("envio_reasignar_cliente", {
605
+ title: "Reasignar un envío a otro cliente",
606
+ description: "Mueve UN envío (por tracking) a otro cliente/vendedor de TU nodo cuando se cargó mal. Deja registro en el historial. Igual que la función del frontend.",
607
+ inputSchema: { tracking: z.string().min(1), cliente: z.string().min(1).describe("nombre o id del cliente destino (de tu nodo)") },
608
+ }, async (args) => run(() => api("POST", "/envios/reasignar-cliente", args)));
609
+ tool("cobro_corregir", {
610
+ title: "Corregir el monto de un cobro",
611
+ description: "Ajusta el monto a cobrar contra entrega de un envío (cuando se cobró de más o de menos), guardando el monto ORIGINAL en el historial. envioId + monto nuevo. No mueve dinero en cuentas (corrige el dato del envío).",
612
+ inputSchema: { envioId: z.number().int().positive(), monto: z.number(), motivo: z.string().optional() },
613
+ }, async ({ envioId, monto, motivo }) => run(() => api("POST", `/envios/${envioId}/corregir-cobro`, { monto, motivo })));
614
+ tool("rendicion_revertir", {
615
+ title: "Revertir una rendición (marcada por error)",
616
+ description: "Revierte un cobro/cambio marcado como 'rendido' cuando en realidad NO se rindió → vuelve a 'a rendir'. Deja registro (quién/cuándo). envioId + tipo (cobro|cambio). Scopeado a tu nodo.",
617
+ inputSchema: { envioId: z.number().int().positive(), tipo: z.enum(["cobro", "cambio"]), motivo: z.string().optional() },
618
+ }, async (args) => run(() => api("POST", "/flujo/rendicion/revertir", args)));
619
+ }
255
620
  if (puede("gestion")) {
621
+ tool("colecta_configurar", {
622
+ title: "Configurar valor de colecta",
623
+ description: "Setea un valor de colecta según alcance: 'nodo' = pago default del nodo (ej. $3000); 'mensajero' = default de ese mensajero (ej. $4000, cualquier colecta); 'cliente' = cuánto se le COBRA al vendedor (idCliente + valor, opcional minEnvios); 'par' = pago pactado a un mensajero por colectar a un cliente (idCliente + mensajero + valor). Scopeado a tu nodo. No mueve dinero (config de tarifa).",
624
+ inputSchema: {
625
+ alcance: z.enum(["nodo", "mensajero", "cliente", "par"]),
626
+ valor: z.number(),
627
+ idCliente: z.string().optional(),
628
+ mensajero: z.string().optional().describe("Nombre del mensajero (de tu nodo)"),
629
+ minEnvios: z.number().optional().describe("Solo alcance=cliente: mínimo de envíos para colecta sin cargo"),
630
+ },
631
+ }, async (args) => run(() => api("POST", "/colecta/config", args)));
632
+
633
+ tool("colecta_asignar", {
634
+ title: "Asignar una colecta a un mensajero",
635
+ description: "Asigna la colecta (retiro de mercadería) de un cliente a un mensajero, ambos por NOMBRE de tu nodo (ej. «que Maxi levante al cliente Distri Sur»). Vincula los envíos 'A retirar' de ese cliente a la colecta y avisa al mensajero. Si el corte venció, la programa para el próximo día hábil. No mueve dinero (es logística).",
636
+ inputSchema: {
637
+ cliente: z.string().min(1).describe("Nombre del cliente/vendedor a colectar"),
638
+ mensajero: z.string().min(1).describe("Nombre del mensajero (de tu nodo) que hace el retiro"),
639
+ },
640
+ }, async (args) => run(() => api("POST", "/colecta/asignar-por-nombre", args)));
641
+
642
+ tool("colecta_desasignar", {
643
+ title: "Desasignar una colecta",
644
+ description: "Quita la asignación de una colecta (los envíos vuelven a 'sin colecta'). El colectaId lo devuelve «colecta_pendientes». No mueve dinero.",
645
+ inputSchema: { colectaId: z.number().int().positive() },
646
+ }, async ({ colectaId }) => run(() => api("POST", "/colecta/desasignar", { colectaId })));
647
+
648
+ tool("procesar_zona", {
649
+ title: "Aceptar envíos ruteados por zona",
650
+ description: "ACEPTA (recibe en tu nodo) los envíos que otros nodos te rutearon por zona — los ids salen de «envios_por_zona». Opcional: mensajeroId para asignarlos a un mensajero puntual de esa zona. No mueve dinero (el clearing es config).",
651
+ inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), mensajeroId: z.number().int().positive().optional() },
652
+ }, async (args) => run(() => api("POST", "/colecta/procesar-zona", args)));
653
+
654
+ tool("rechazar_zona", {
655
+ title: "Rechazar envíos ruteados por zona",
656
+ description: "RECHAZA (declina) envíos que te rutearon por zona: dejan de aparecerte y quedan para el nodo de origen u otros nodos de la zona. No cambia el estado del envío. Los ids salen de «envios_por_zona».",
657
+ inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), motivo: z.string().optional() },
658
+ }, async (args) => run(() => api("POST", "/colecta/rechazar-zona", args)));
659
+
256
660
  tool("cliente_crear", {
257
661
  title: "Crear cliente / vendedor",
258
662
  description: "Da de alta un cliente en TU nodo (el backend fuerza el nodo). Podés asignarle la lista de precio con idLista. No toca dinero.",
@@ -325,6 +729,15 @@ if (ALLOW_PRECIOS && puede("precios")) {
325
729
  }, async (args) => run(() => api("POST", "/precios/clientes/version", args)));
326
730
  }
327
731
 
732
+ // Ayuda integrada — se registra al final para que el índice conozca TODOS los tools
733
+ // que este usuario tiene según su rol/permisos. Solo lectura de documentación.
734
+ tool("ayuda", {
735
+ title: "Ayuda / documentación de las herramientas",
736
+ description: "Documentación de las herramientas del MCP: sin argumentos = índice de lo que podés usar; `tool` = ficha de una herramienta (qué hace, cómo usarla, ejemplo, qué NO hace); `tema` = tópico transversal (facturacion_marketplace, dinero, aislamiento). Consultalo antes de asumir cómo funciona algo.",
737
+ inputSchema: { tool: z.string().optional().describe("Nombre de una herramienta, ej. ruta_adjudicar"), tema: z.string().optional().describe("facturacion_marketplace | dinero | aislamiento") },
738
+ }, async ({ tool: t, tema }) =>
739
+ ({ content: [{ type: "text", text: renderAyuda(t || tema, { version: MCP_VERSION, disponibles: registrados }) }] }));
740
+
328
741
  const transport = new StdioServerTransport();
329
742
  await server.connect(transport);
330
743
  const cap = isCliente ? "cliente" : esGlobal ? "admin global" : isStaff ? "staff de nodo" : "sin identidad";