nexusflex-mcp 3.0.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.
package/server.mjs CHANGED
@@ -1,315 +1,744 @@
1
- #!/usr/bin/env node
2
- // ============================================================================
3
- // MCP Nexus Flex v3 — servidor stdio (paquete publicable en npm).
4
- //
5
- // INSTALACIÓN FÁCIL: npx -y nexusflex-mcp@latest (arranca el server)
6
- // npx -y nexusflex-mcp@latest login (autoriza en el navegador)
7
- // npx -y nexusflex-mcp@latest logout (borra el token local)
8
- //
9
- // AUTENTICACIÓN por DEVICE-FLOW (autorización web): sin pegar email/contraseña.
10
- // El MCP pide un código, lo autorizás con un click desde la web ya logueado, y
11
- // recibe un token de vida larga, revocable y con TU scope exacto (nunca dinero).
12
- // Fallback: NEXUSFLEX_TOKEN o NEXUSFLEX_EMAIL+PASSWORD (compatibilidad).
13
- //
14
- // AISLAMIENTO EN 3 CAPAS:
15
- // 1) El BACKEND gatea cada endpoint (requireAuth + requirePermiso + scope por
16
- // nodo / idCliente). Un token de MCP hereda el rol/permisos FRESCOS del
17
- // usuario en cada request. Es la garantía real.
18
- // 2) Este server registra los tools SEGÚN EL ROL del usuario (leído de /auth/me).
19
- // 3) DENYLIST de dinero en api.mjs + guard server-side (mcpMoneyGuard): jamás
20
- // liquidaciones/cobros/cuentas/facturación.
21
- //
22
- // Escritura por flags (default OFF):
23
- // NEXUSFLEX_MCP_ALLOW_WRITE → altas (clientes, productos, nodos) y edición.
24
- // NEXUSFLEX_MCP_ALLOW_PRECIOS → actualizar listas de precios (aparte, sensible).
25
- // ============================================================================
26
- import { runDeviceFlow, saveToken, clearToken, tokenFilePath } from "./device-auth.mjs";
27
- import { api, log, API_URL } from "./api.mjs";
28
-
29
- // --- Subcomandos de línea de comando (login/logout) antes de arrancar el server ---
30
- const cmd = process.argv[2];
31
- if (cmd === "login") {
32
- try {
33
- const token = await runDeviceFlow(API_URL, { open: true, log });
34
- const file = saveToken(token, API_URL);
35
- log(`Token guardado en ${file}. Ya podés usar el MCP en Claude Desktop.`);
36
- process.exit(0);
37
- } catch (e) {
38
- log("No se pudo autorizar:", e instanceof Error ? e.message : String(e));
39
- process.exit(1);
40
- }
41
- }
42
- if (cmd === "logout") {
43
- clearToken();
44
- log(`Token local borrado (${tokenFilePath()}). Revocá también desde la web (🔌 Conexiones) si querés cortar el acceso ya emitido.`);
45
- process.exit(0);
46
- }
47
-
48
- const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
49
- const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
50
- const { z } = await import("zod");
51
-
52
- const truthy = (v) => /^(1|true|yes|si|sí)$/i.test(v ?? "");
53
- const ALLOW_WRITE = truthy(process.env.NEXUSFLEX_MCP_ALLOW_WRITE);
54
- const ALLOW_PRECIOS = truthy(process.env.NEXUSFLEX_MCP_ALLOW_PRECIOS);
55
-
56
- const server = new McpServer({ name: "nexusflex", version: "3.0.0" });
57
-
58
- /** Formatea un ApiResult como respuesta de tool MCP, con mensajes claros y SIN
59
- * filtrar tokens ni stack traces. */
60
- function toResult(r) {
61
- if (r.ok) return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
62
- const base =
63
- r.status === 401 ? "Sesión inválida o vencida (autorizá de nuevo con `npx nexusflex-mcp login`)." :
64
- r.status === 403 ? "Tu usuario no tiene permiso para esta operación (aislamiento del backend)." :
65
- r.status === 404 ? "No encontrado." :
66
- `Error ${r.status}.`;
67
- const d = r.data;
68
- const detalle = d?.error ? ` ${d.error}` : d?.message ? ` ${d.message}` : "";
69
- return { content: [{ type: "text", text: `${base}${detalle}` }], isError: true };
70
- }
71
-
72
- async function run(fn) {
73
- try {
74
- return toResult(await fn());
75
- } catch (e) {
76
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
77
- }
78
- }
79
-
80
- const q = (params) => {
81
- const s = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
82
- return s ? `?${s}` : "";
83
- };
84
- const tool = (name, def, handler) => server.registerTool(name, def, handler);
85
-
86
- // ============================================================================
87
- // CAPA 2 — Identidad: leemos /auth/me ANTES de registrar tools. Cada usuario ve
88
- // SOLO los tools de su rol. Si falla el login, se registra solo `mis_datos`.
89
- // ============================================================================
90
- let me = null;
91
- try {
92
- const r = await api("GET", "/auth/me");
93
- if (r.ok) me = r.data?.usuario ?? null;
94
- else log(`/auth/me devolvió ${r.status} — revisá el token/credenciales.`);
95
- } catch (e) {
96
- log("No se pudo contactar /auth/me:", e instanceof Error ? e.message : String(e));
97
- }
98
-
99
- const rol = me?.rol ?? "";
100
- const permisos = Array.isArray(me?.permisos) ? me.permisos : [];
101
- const esGlobal = rol === "admin" || me?.esSuperoperador === true;
102
- const esAdminNodo = me?.esAdminNodo === true;
103
- const isCliente = rol === "cliente";
104
- const isStaff = rol === "operador" || rol === "admin";
105
- const wmsActivo = me?.wmsActivo === true;
106
- // Espejo de requirePermiso del backend (solo para DECIDIR qué mostrar; el backend manda).
107
- const puede = (p) => esGlobal || (isStaff && (esAdminNodo || permisos.length === 0 || permisos.includes(p)));
108
-
109
- // ============================================================================
110
- // SIEMPRE
111
- // ============================================================================
112
- tool("mis_datos", {
113
- title: "Mis datos / alcance",
114
- description: "Devuelve tu usuario, rol y nodo/cliente al que está atado este MCP. Usalo para confirmar tu alcance antes de operar.",
115
- inputSchema: {},
116
- }, async () => run(() => api("GET", "/auth/me")));
117
-
118
- // ============================================================================
119
- // ROL CLIENTE (vendedor) — SOLO lo suyo. El backend lo fuerza a su idCliente.
120
- // ============================================================================
121
- if (isCliente) {
122
- tool("mis_envios", {
123
- title: "Mis envíos",
124
- description: "Tus envíos/paquetes (solo los tuyos). No incluye datos de otros clientes ni del nodo.",
125
- inputSchema: { estado: z.string().optional().describe("Filtrar por estado (opcional)") },
126
- }, async ({ estado }) => run(() => api("GET", `/portal/envios${q({ estado })}`)));
127
-
128
- tool("mis_kpis", {
129
- title: "Mis métricas",
130
- description: "Tus métricas: volumen, calidad de entrega, saldo, última liquidación y un benchmark ANÓNIMO contra el promedio de tu nodo (nunca ves a quién corresponde cada número).",
131
- inputSchema: { desde: z.string().optional(), hasta: z.string().optional() },
132
- }, async ({ desde, hasta }) => run(() => api("GET", `/kpi/vendedor${q({ desde, hasta })}`)));
133
-
134
- // --- Control de stock del vendedor (WMS Fase 1). Se registran SIEMPRE para
135
- // rol=cliente: el backend gatea con 403 si el vendedor no tiene depósito ni
136
- // control de stock propio. Así funciona tanto con WMS del nodo como con el
137
- // modo "soft" por vendedor (Cliente.controlStockActivo). ---
138
- tool("mi_stock", {
139
- title: "Mi stock",
140
- description: "Tu stock físico en el depósito (solo tus productos). Requiere tener depósito o control de stock habilitado.",
141
- inputSchema: {},
142
- }, async () => run(() => api("GET", "/wms/stock")));
143
-
144
- tool("mi_disponible", {
145
- title: "Mi disponible para vender",
146
- description: "Disponible-para-vender por SKU = stock físico − comprometido en pedidos pendientes. Marca ⚠️ cuando un SKU está por debajo del mínimo. Solo tus productos.",
147
- inputSchema: {},
148
- }, async () => run(() => api("GET", "/wms/stock/disponible")));
149
-
150
- tool("mi_rentabilidad", {
151
- title: "Mi rentabilidad por SKU",
152
- description: "Margen por SKU en un rango (default: mes en curso) = precio de venta − costo de envío real − COGS opcional. Solo tus productos. Fechas YYYY-MM-DD.",
153
- inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
154
- }, async ({ desde, hasta }) => run(() => api("GET", `/wms/rentabilidad${q({ desde, hasta })}`)));
155
-
156
- tool("mis_productos", {
157
- title: "Mi catálogo",
158
- description: "Tu catálogo de productos en el depósito.",
159
- inputSchema: {},
160
- }, async () => run(() => api("GET", "/wms/productos")));
161
-
162
- tool("mis_top_productos", {
163
- title: "Mis productos más despachados",
164
- description: "Ranking de TUS productos más despachados en un rango (default: mes en curso). Solo tus productos.",
165
- inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
166
- }, async ({ desde, hasta }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta })}`)));
167
- }
168
-
169
- // ============================================================================
170
- // ROL STAFF DE NODO — datos de SU nodo (gateado por permiso; el backend fuerza el nodo).
171
- // ============================================================================
172
- if (isStaff) {
173
- if (puede("gestion")) {
174
- tool("clientes_del_nodo", {
175
- title: "Clientes y listas del nodo",
176
- description: "Clientes/vendedores del nodo con su lista de precio asignada, más las listas disponibles. Scopeado a tu nodo.",
177
- inputSchema: {},
178
- }, async () => run(() => api("GET", "/gestion/formularios")));
179
- }
180
-
181
- if (puede("precios")) {
182
- tool("precios_ver", {
183
- title: "Ver listas de precios",
184
- description: "Las listas de precios (por zona: cercana/media/lejana/muy lejana) de tu nodo. Solo lectura.",
185
- inputSchema: {},
186
- }, async () => run(() => api("GET", "/precios/clientes")));
187
- }
188
-
189
- if (puede("reportes")) {
190
- tool("kpi_nodo", {
191
- title: "Métricas del nodo",
192
- description: "Tablero del nodo: volumen y entregas con variación mensual, P&L real, top de clientes y clientes en caída. Solo lectura, scopeado a tu nodo.",
193
- inputSchema: { desde: z.string().optional(), hasta: z.string().optional(), nodo: z.number().optional().describe("Solo para admin global: elegir nodo") },
194
- }, async ({ desde, hasta, nodo }) => run(() => api("GET", `/kpi/nodo${q({ desde, hasta, nodo })}`)));
195
- }
196
-
197
- if (puede("wms") && wmsActivo) {
198
- tool("stock_nodo", {
199
- title: "Stock del nodo",
200
- description: "Stock del depósito del nodo. Opcional: filtrar por un cliente.",
201
- inputSchema: { cliente: z.string().optional().describe("idCliente para filtrar (opcional)") },
202
- }, async ({ cliente }) => run(() => api("GET", `/wms/stock${q({ cliente })}`)));
203
-
204
- tool("productos_nodo", {
205
- title: "Catálogo del nodo",
206
- description: "Catálogo de productos del depósito del nodo. Opcional: filtrar por cliente.",
207
- inputSchema: { cliente: z.string().optional() },
208
- }, async ({ cliente }) => run(() => api("GET", `/wms/productos${q({ cliente })}`)));
209
-
210
- tool("top_productos", {
211
- title: "Productos más despachados del nodo",
212
- description: "Ranking de productos más despachados del nodo en un rango (default: mes en curso). Opcional: filtrar por un cliente.",
213
- inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD"), cliente: z.string().optional() },
214
- }, async ({ desde, hasta, cliente }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta, cliente })}`)));
215
- }
216
- }
217
-
218
- // ============================================================================
219
- // ROL GLOBAL (superadmin / superoperador)
220
- // ============================================================================
221
- if (esGlobal) {
222
- tool("nodos_listar", {
223
- title: "Listar nodos",
224
- description: "Lista todas las logísticas (nodos) con sus conteos. Solo admin global.",
225
- inputSchema: {},
226
- }, async () => run(() => api("GET", "/logisticas")));
227
-
228
- tool("kpi_red", {
229
- title: "Métricas de la red (SaaS)",
230
- description: "KPIs globales de toda la red de nodos (crecimiento, operacional, volumen de clearing). Solo admin global.",
231
- inputSchema: {},
232
- }, async () => run(() => api("GET", "/kpi/saas")));
233
- }
234
-
235
- // ============================================================================
236
- // ESCRITURA (opt-in por flag · NUNCA dinero). Además gateado por permiso en el backend.
237
- // ============================================================================
238
- if (ALLOW_WRITE) {
239
- if (puede("gestion")) {
240
- tool("cliente_crear", {
241
- title: "Crear cliente / vendedor",
242
- 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.",
243
- inputSchema: {
244
- nombre: z.string().min(1).describe("Nombre del cliente"),
245
- telefono: z.string().optional(),
246
- dni: z.string().optional(),
247
- direccion: z.string().optional(),
248
- idLista: z.string().optional().describe("ID de la lista de precios a asignar (ej. 'B'). Consultá 'clientes_del_nodo'."),
249
- },
250
- }, async (args) => run(() => api("POST", "/gestion/clientes", args)));
251
-
252
- tool("cliente_editar", {
253
- title: "Editar cliente (incluye cambiar su lista)",
254
- description: "Edita un cliente de TU nodo. Para cambiarle la lista de precio pasá idLista. El nombre es obligatorio (traelo de 'clientes_del_nodo'). No toca dinero.",
255
- inputSchema: {
256
- id: z.number().int().positive().describe("ID del cliente"),
257
- nombre: z.string().min(1).describe("Nombre actual del cliente (obligatorio)"),
258
- idLista: z.string().optional().describe("Nueva lista de precio a asignar"),
259
- telefono: z.string().optional(),
260
- dni: z.string().optional(),
261
- direccion: z.string().optional(),
262
- },
263
- }, async ({ id, ...body }) => run(() => api("PUT", `/gestion/clientes/${id}`, body)));
264
- }
265
-
266
- if (puede("wms") && wmsActivo) {
267
- tool("producto_crear", {
268
- title: "Crear producto (WMS)",
269
- description: "Alta de un producto en el catálogo del depósito. Staff puede indicar el cliente dueño con idCliente. No toca dinero.",
270
- inputSchema: {
271
- nombre: z.string().min(1),
272
- sku: z.string().optional(),
273
- codigoBarra: z.string().optional(),
274
- peso: z.number().optional(),
275
- volumen: z.number().optional(),
276
- idCliente: z.string().optional().describe("idCliente dueño del producto"),
277
- },
278
- }, async (args) => run(() => api("POST", "/wms/productos", args)));
279
- }
280
-
281
- if (esGlobal) {
282
- tool("nodo_crear", {
283
- title: "Crear nodo (logística)",
284
- description: "Da de alta un nodo/logística nuevo. Solo admin global. No toca dinero.",
285
- inputSchema: {
286
- nombre: z.string().min(1).describe("Nombre del nodo/logística"),
287
- telefono: z.string().optional(),
288
- },
289
- }, async ({ nombre, telefono }) => run(() => api("POST", "/logisticas", { nombre, telefono: telefono ?? null })));
290
- }
291
- }
292
-
293
- // ============================================================================
294
- // EDICIÓN DE PRECIOS (flag aparte · sensible pero NO mueve dinero).
295
- // ============================================================================
296
- if (ALLOW_PRECIOS && puede("precios")) {
297
- tool("precio_actualizar", {
298
- title: "Actualizar precio de una lista (versionado)",
299
- description: "Cambia los precios por zona de una lista creando una VERSIÓN nueva (histórico exacto). Requiere permiso 'precios'; el backend impide tocar listas de otro nodo. No mueve dinero.",
300
- inputSchema: {
301
- idLista: z.string().min(1).describe("ID de la lista (ej. 'B'). Ver 'precios_ver'."),
302
- cercana: z.number().optional().describe("Precio zona cercana"),
303
- media: z.number().optional().describe("Precio zona media"),
304
- lejana: z.number().optional().describe("Precio zona lejana"),
305
- muyLejana: z.number().optional().describe("Precio zona muy lejana"),
306
- referencia: z.string().optional(),
307
- vigenciaDesde: z.string().optional().describe("YYYY-MM-DD desde cuándo rige (default: hoy)"),
308
- },
309
- }, async (args) => run(() => api("POST", "/precios/clientes/version", args)));
310
- }
311
-
312
- const transport = new StdioServerTransport();
313
- await server.connect(transport);
314
- const cap = isCliente ? "cliente" : esGlobal ? "admin global" : isStaff ? "staff de nodo" : "sin identidad";
315
- log(`MCP Nexus Flex v3 listo. Rol: ${cap}. Escritura: ${ALLOW_WRITE ? "ON" : "off"} · Precios: ${ALLOW_PRECIOS ? "ON" : "off"}.`);
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // MCP Nexus Flex v3 — servidor stdio (paquete publicable en npm).
4
+ //
5
+ // INSTALACIÓN FÁCIL: npx -y nexusflex-mcp@latest (arranca el server)
6
+ // npx -y nexusflex-mcp@latest login (autoriza en el navegador)
7
+ // npx -y nexusflex-mcp@latest logout (borra el token local)
8
+ //
9
+ // AUTENTICACIÓN por DEVICE-FLOW (autorización web): sin pegar email/contraseña.
10
+ // El MCP pide un código, lo autorizás con un click desde la web ya logueado, y
11
+ // recibe un token de vida larga, revocable y con TU scope exacto (nunca dinero).
12
+ // Fallback: NEXUSFLEX_TOKEN o NEXUSFLEX_EMAIL+PASSWORD (compatibilidad).
13
+ //
14
+ // AISLAMIENTO EN 3 CAPAS:
15
+ // 1) El BACKEND gatea cada endpoint (requireAuth + requirePermiso + scope por
16
+ // nodo / idCliente). Un token de MCP hereda el rol/permisos FRESCOS del
17
+ // usuario en cada request. Es la garantía real.
18
+ // 2) Este server registra los tools SEGÚN EL ROL del usuario (leído de /auth/me).
19
+ // 3) DENYLIST de dinero en api.mjs + guard server-side (mcpMoneyGuard): jamás
20
+ // liquidaciones/cobros/cuentas/facturación.
21
+ //
22
+ // Escritura por flags (default OFF):
23
+ // NEXUSFLEX_MCP_ALLOW_WRITE → altas (clientes, productos, nodos) y edición.
24
+ // NEXUSFLEX_MCP_ALLOW_PRECIOS → actualizar listas de precios (aparte, sensible).
25
+ // ============================================================================
26
+ import { runDeviceFlow, saveToken, clearToken, tokenFilePath } from "./device-auth.mjs";
27
+ import { api, log, API_URL } from "./api.mjs";
28
+ import { renderAyuda, guiaOnboarding } from "./docs.mjs";
29
+
30
+ // --- Subcomandos de línea de comando (login/logout) antes de arrancar el server ---
31
+ const cmd = process.argv[2];
32
+ if (cmd === "login") {
33
+ try {
34
+ const token = await runDeviceFlow(API_URL, { open: true, log });
35
+ const file = saveToken(token, API_URL);
36
+ log(`Token guardado en ${file}. Ya podés usar el MCP en Claude Desktop.`);
37
+ process.exit(0);
38
+ } catch (e) {
39
+ log("No se pudo autorizar:", e instanceof Error ? e.message : String(e));
40
+ process.exit(1);
41
+ }
42
+ }
43
+ if (cmd === "logout") {
44
+ clearToken();
45
+ log(`Token local borrado (${tokenFilePath()}). Revocá también desde la web (🔌 Conexiones) si querés cortar el acceso ya emitido.`);
46
+ process.exit(0);
47
+ }
48
+
49
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
50
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
51
+ const { z } = await import("zod");
52
+
53
+ const truthy = (v) => /^(1|true|yes|si|sí)$/i.test(v ?? "");
54
+ const ALLOW_WRITE = truthy(process.env.NEXUSFLEX_MCP_ALLOW_WRITE);
55
+ const ALLOW_PRECIOS = truthy(process.env.NEXUSFLEX_MCP_ALLOW_PRECIOS);
56
+
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 });
66
+
67
+ /** Formatea un ApiResult como respuesta de tool MCP, con mensajes claros y SIN
68
+ * filtrar tokens ni stack traces. */
69
+ function toResult(r) {
70
+ if (r.ok) return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
71
+ const base =
72
+ r.status === 401 ? "Sesión inválida o vencida (autorizá de nuevo con `npx nexusflex-mcp login`)." :
73
+ r.status === 403 ? "Tu usuario no tiene permiso para esta operación (aislamiento del backend)." :
74
+ r.status === 404 ? "No encontrado." :
75
+ `Error ${r.status}.`;
76
+ const d = r.data;
77
+ const detalle = d?.error ? ` ${d.error}` : d?.message ? ` ${d.message}` : "";
78
+ return { content: [{ type: "text", text: `${base}${detalle}` }], isError: true };
79
+ }
80
+
81
+ async function run(fn) {
82
+ try {
83
+ return toResult(await fn());
84
+ } catch (e) {
85
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
86
+ }
87
+ }
88
+
89
+ const q = (params) => {
90
+ const s = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
91
+ return s ? `?${s}` : "";
92
+ };
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); };
95
+
96
+ // ============================================================================
97
+ // CAPA 2 — Identidad: leemos /auth/me ANTES de registrar tools. Cada usuario ve
98
+ // SOLO los tools de su rol. Si falla el login, se registra solo `mis_datos`.
99
+ // ============================================================================
100
+ let me = null;
101
+ try {
102
+ const r = await api("GET", "/auth/me");
103
+ if (r.ok) me = r.data?.usuario ?? null;
104
+ else log(`/auth/me devolvió ${r.status} — revisá el token/credenciales.`);
105
+ } catch (e) {
106
+ log("No se pudo contactar /auth/me:", e instanceof Error ? e.message : String(e));
107
+ }
108
+
109
+ const rol = me?.rol ?? "";
110
+ const permisos = Array.isArray(me?.permisos) ? me.permisos : [];
111
+ const esGlobal = rol === "admin" || me?.esSuperoperador === true;
112
+ const esAdminNodo = me?.esAdminNodo === true;
113
+ const isCliente = rol === "cliente";
114
+ const isStaff = rol === "operador" || rol === "admin";
115
+ const wmsActivo = me?.wmsActivo === true;
116
+ // Espejo de requirePermiso del backend (solo para DECIDIR qué mostrar; el backend manda).
117
+ const puede = (p) => esGlobal || (isStaff && (esAdminNodo || permisos.length === 0 || permisos.includes(p)));
118
+
119
+ // ============================================================================
120
+ // SIEMPRE
121
+ // ============================================================================
122
+ tool("mis_datos", {
123
+ title: "Mis datos / alcance",
124
+ description: "Devuelve tu usuario, rol y nodo/cliente al que está atado este MCP. Usalo para confirmar tu alcance antes de operar.",
125
+ inputSchema: {},
126
+ }, async () => run(() => api("GET", "/auth/me")));
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
+
153
+ // ============================================================================
154
+ // ROL CLIENTE (vendedor) — SOLO lo suyo. El backend lo fuerza a su idCliente.
155
+ // ============================================================================
156
+ if (isCliente) {
157
+ tool("mis_envios", {
158
+ title: "Mis envíos",
159
+ description: "Tus envíos/paquetes (solo los tuyos). No incluye datos de otros clientes ni del nodo.",
160
+ inputSchema: { estado: z.string().optional().describe("Filtrar por estado (opcional)") },
161
+ }, async ({ estado }) => run(() => api("GET", `/portal/envios${q({ estado })}`)));
162
+
163
+ tool("mis_kpis", {
164
+ title: "Mis métricas",
165
+ description: "Tus métricas: volumen, calidad de entrega, saldo, última liquidación y un benchmark ANÓNIMO contra el promedio de tu nodo (nunca ves a quién corresponde cada número).",
166
+ inputSchema: { desde: z.string().optional(), hasta: z.string().optional() },
167
+ }, async ({ desde, hasta }) => run(() => api("GET", `/kpi/vendedor${q({ desde, hasta })}`)));
168
+
169
+ // --- Control de stock del vendedor (WMS Fase 1). Se registran SIEMPRE para
170
+ // rol=cliente: el backend gatea con 403 si el vendedor no tiene depósito ni
171
+ // control de stock propio. Así funciona tanto con WMS del nodo como con el
172
+ // modo "soft" por vendedor (Cliente.controlStockActivo). ---
173
+ tool("mi_stock", {
174
+ title: "Mi stock",
175
+ description: "Tu stock físico en el depósito (solo tus productos). Requiere tener depósito o control de stock habilitado.",
176
+ inputSchema: {},
177
+ }, async () => run(() => api("GET", "/wms/stock")));
178
+
179
+ tool("mi_disponible", {
180
+ title: "Mi disponible para vender",
181
+ description: "Disponible-para-vender por SKU = stock físico − comprometido en pedidos pendientes. Marca ⚠️ cuando un SKU está por debajo del mínimo. Solo tus productos.",
182
+ inputSchema: {},
183
+ }, async () => run(() => api("GET", "/wms/stock/disponible")));
184
+
185
+ tool("mi_rentabilidad", {
186
+ title: "Mi rentabilidad por SKU",
187
+ description: "Margen por SKU en un rango (default: mes en curso) = precio de venta − costo de envío real − COGS opcional. Solo tus productos. Fechas YYYY-MM-DD.",
188
+ inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
189
+ }, async ({ desde, hasta }) => run(() => api("GET", `/wms/rentabilidad${q({ desde, hasta })}`)));
190
+
191
+ tool("mis_productos", {
192
+ title: "Mi catálogo",
193
+ description: "Tu catálogo de productos en el depósito.",
194
+ inputSchema: {},
195
+ }, async () => run(() => api("GET", "/wms/productos")));
196
+
197
+ tool("mis_top_productos", {
198
+ title: "Mis productos más despachados",
199
+ description: "Ranking de TUS productos más despachados en un rango (default: mes en curso). Solo tus productos.",
200
+ inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
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")));
228
+ }
229
+
230
+ // ============================================================================
231
+ // ROL STAFF DE NODO — datos de SU nodo (gateado por permiso; el backend fuerza el nodo).
232
+ // ============================================================================
233
+ if (isStaff) {
234
+ if (puede("gestion")) {
235
+ tool("clientes_del_nodo", {
236
+ title: "Clientes y listas del nodo",
237
+ description: "Clientes/vendedores del nodo con su lista de precio asignada, más las listas disponibles. Scopeado a tu nodo.",
238
+ inputSchema: {},
239
+ }, async () => run(() => api("GET", "/gestion/formularios")));
240
+ }
241
+
242
+ if (puede("precios")) {
243
+ tool("precios_ver", {
244
+ title: "Ver listas de precios",
245
+ description: "Las listas de precios (por zona: cercana/media/lejana/muy lejana) de tu nodo. Solo lectura.",
246
+ inputSchema: {},
247
+ }, async () => run(() => api("GET", "/precios/clientes")));
248
+ }
249
+
250
+ if (puede("reportes")) {
251
+ tool("kpi_nodo", {
252
+ title: "Métricas del nodo",
253
+ description: "Tablero del nodo: volumen y entregas con variación mensual, P&L real, top de clientes y clientes en caída. Solo lectura, scopeado a tu nodo.",
254
+ inputSchema: { desde: z.string().optional(), hasta: z.string().optional(), nodo: z.number().optional().describe("Solo para admin global: elegir nodo") },
255
+ }, async ({ desde, hasta, nodo }) => run(() => api("GET", `/kpi/nodo${q({ desde, hasta, nodo })}`)));
256
+ }
257
+
258
+ if (puede("wms") && wmsActivo) {
259
+ tool("stock_nodo", {
260
+ title: "Stock del nodo",
261
+ description: "Stock del depósito del nodo. Opcional: filtrar por un cliente.",
262
+ inputSchema: { cliente: z.string().optional().describe("idCliente para filtrar (opcional)") },
263
+ }, async ({ cliente }) => run(() => api("GET", `/wms/stock${q({ cliente })}`)));
264
+
265
+ tool("productos_nodo", {
266
+ title: "Catálogo del nodo",
267
+ description: "Catálogo de productos del depósito del nodo. Opcional: filtrar por cliente.",
268
+ inputSchema: { cliente: z.string().optional() },
269
+ }, async ({ cliente }) => run(() => api("GET", `/wms/productos${q({ cliente })}`)));
270
+
271
+ tool("top_productos", {
272
+ title: "Productos más despachados del nodo",
273
+ description: "Ranking de productos más despachados del nodo en un rango (default: mes en curso). Opcional: filtrar por un cliente.",
274
+ inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD"), cliente: z.string().optional() },
275
+ }, async ({ desde, hasta, cliente }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta, cliente })}`)));
276
+ }
277
+ }
278
+
279
+ // ============================================================================
280
+ // ROL GLOBAL (superadmin / superoperador)
281
+ // ============================================================================
282
+ if (esGlobal) {
283
+ tool("nodos_listar", {
284
+ title: "Listar nodos",
285
+ description: "Lista todas las logísticas (nodos) con sus conteos. Solo admin global.",
286
+ inputSchema: {},
287
+ }, async () => run(() => api("GET", "/logisticas")));
288
+
289
+ tool("kpi_red", {
290
+ title: "Métricas de la red (SaaS)",
291
+ description: "KPIs globales de toda la red de nodos (crecimiento, operacional, volumen de clearing). Solo admin global.",
292
+ inputSchema: {},
293
+ }, async () => run(() => api("GET", "/kpi/saas")));
294
+ }
295
+
296
+ // ============================================================================
297
+ // VINCULACIÓN DE TIENDAS — genera el enlace de autorización (OAuth) para traer las
298
+ // ventas de una tienda a Nexus Flex. Un vendedor genera el SUYO; el staff (permiso
299
+ // gestion) puede generarlo PARA un cliente de SU nodo y mandárselo. No toca dinero.
300
+ // ============================================================================
301
+ if (isCliente || (isStaff && puede("gestion"))) {
302
+ tool("generar_enlace_vinculacion", {
303
+ title: "Generar enlace de vinculación de tienda",
304
+ description: "Genera el enlace (link) para vincular una tienda —Mercado Libre, TiendaNube o Tienda Negocio— y traer sus ventas a Nexus Flex. Si sos vendedor genera el TUYO; si sos staff podés generarlo PARA un cliente de tu nodo pasando 'cliente' (idCliente de 'clientes_del_nodo') y mandarle ese enlace para que lo autorice desde su propia cuenta de la tienda. El enlace vence en 15 minutos. No toca dinero.",
305
+ inputSchema: {
306
+ proveedor: z.enum(["ml", "tiendanube", "tiendanegocio"]).describe("ml = Mercado Libre · tiendanube · tiendanegocio"),
307
+ cliente: z.string().optional().describe("Solo staff: idCliente del vendedor para el que generás el enlace. El backend valida que sea de TU nodo (si no, 403)."),
308
+ },
309
+ }, async ({ proveedor, cliente }) => run(() => api("GET", `/${proveedor}/auth${q({ cliente })}`)));
310
+ }
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
+
485
+ // ============================================================================
486
+ // ESCRITURA (opt-in por flag · NUNCA dinero). Además gateado por permiso en el backend.
487
+ // ============================================================================
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
+ }
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
+
660
+ tool("cliente_crear", {
661
+ title: "Crear cliente / vendedor",
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.",
663
+ inputSchema: {
664
+ nombre: z.string().min(1).describe("Nombre del cliente"),
665
+ telefono: z.string().optional(),
666
+ dni: z.string().optional(),
667
+ direccion: z.string().optional(),
668
+ idLista: z.string().optional().describe("ID de la lista de precios a asignar (ej. 'B'). Consultá 'clientes_del_nodo'."),
669
+ },
670
+ }, async (args) => run(() => api("POST", "/gestion/clientes", args)));
671
+
672
+ tool("cliente_editar", {
673
+ title: "Editar cliente (incluye cambiar su lista)",
674
+ description: "Edita un cliente de TU nodo. Para cambiarle la lista de precio pasá idLista. El nombre es obligatorio (traelo de 'clientes_del_nodo'). No toca dinero.",
675
+ inputSchema: {
676
+ id: z.number().int().positive().describe("ID del cliente"),
677
+ nombre: z.string().min(1).describe("Nombre actual del cliente (obligatorio)"),
678
+ idLista: z.string().optional().describe("Nueva lista de precio a asignar"),
679
+ telefono: z.string().optional(),
680
+ dni: z.string().optional(),
681
+ direccion: z.string().optional(),
682
+ },
683
+ }, async ({ id, ...body }) => run(() => api("PUT", `/gestion/clientes/${id}`, body)));
684
+ }
685
+
686
+ if (puede("wms") && wmsActivo) {
687
+ tool("producto_crear", {
688
+ title: "Crear producto (WMS)",
689
+ description: "Alta de un producto en el catálogo del depósito. Staff puede indicar el cliente dueño con idCliente. No toca dinero.",
690
+ inputSchema: {
691
+ nombre: z.string().min(1),
692
+ sku: z.string().optional(),
693
+ codigoBarra: z.string().optional(),
694
+ peso: z.number().optional(),
695
+ volumen: z.number().optional(),
696
+ idCliente: z.string().optional().describe("idCliente dueño del producto"),
697
+ },
698
+ }, async (args) => run(() => api("POST", "/wms/productos", args)));
699
+ }
700
+
701
+ if (esGlobal) {
702
+ tool("nodo_crear", {
703
+ title: "Crear nodo (logística)",
704
+ description: "Da de alta un nodo/logística nuevo. Solo admin global. No toca dinero.",
705
+ inputSchema: {
706
+ nombre: z.string().min(1).describe("Nombre del nodo/logística"),
707
+ telefono: z.string().optional(),
708
+ },
709
+ }, async ({ nombre, telefono }) => run(() => api("POST", "/logisticas", { nombre, telefono: telefono ?? null })));
710
+ }
711
+ }
712
+
713
+ // ============================================================================
714
+ // EDICIÓN DE PRECIOS (flag aparte · sensible pero NO mueve dinero).
715
+ // ============================================================================
716
+ if (ALLOW_PRECIOS && puede("precios")) {
717
+ tool("precio_actualizar", {
718
+ title: "Actualizar precio de una lista (versionado)",
719
+ description: "Cambia los precios por zona de una lista creando una VERSIÓN nueva (histórico exacto). Requiere permiso 'precios'; el backend impide tocar listas de otro nodo. No mueve dinero.",
720
+ inputSchema: {
721
+ idLista: z.string().min(1).describe("ID de la lista (ej. 'B'). Ver 'precios_ver'."),
722
+ cercana: z.number().optional().describe("Precio zona cercana"),
723
+ media: z.number().optional().describe("Precio zona media"),
724
+ lejana: z.number().optional().describe("Precio zona lejana"),
725
+ muyLejana: z.number().optional().describe("Precio zona muy lejana"),
726
+ referencia: z.string().optional(),
727
+ vigenciaDesde: z.string().optional().describe("YYYY-MM-DD desde cuándo rige (default: hoy)"),
728
+ },
729
+ }, async (args) => run(() => api("POST", "/precios/clientes/version", args)));
730
+ }
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
+
741
+ const transport = new StdioServerTransport();
742
+ await server.connect(transport);
743
+ const cap = isCliente ? "cliente" : esGlobal ? "admin global" : isStaff ? "staff de nodo" : "sin identidad";
744
+ log(`MCP Nexus Flex v3 listo. Rol: ${cap}. Escritura: ${ALLOW_WRITE ? "ON" : "off"} · Precios: ${ALLOW_PRECIOS ? "ON" : "off"}.`);