nexusflex-mcp 3.0.0 → 3.1.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 (7) hide show
  1. package/INSTALAR.md +97 -97
  2. package/LICENSE +21 -21
  3. package/README.md +52 -52
  4. package/api.mjs +135 -135
  5. package/device-auth.mjs +132 -132
  6. package/package.json +29 -29
  7. package/server.mjs +331 -315
package/server.mjs CHANGED
@@ -1,315 +1,331 @@
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
+
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.1.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
+ // VINCULACIÓN DE TIENDAS — genera el enlace de autorización (OAuth) para traer las
237
+ // ventas de una tienda a Nexus Flex. Un vendedor genera el SUYO; el staff (permiso
238
+ // gestion) puede generarlo PARA un cliente de SU nodo y mandárselo. No toca dinero.
239
+ // ============================================================================
240
+ if (isCliente || (isStaff && puede("gestion"))) {
241
+ tool("generar_enlace_vinculacion", {
242
+ title: "Generar enlace de vinculación de tienda",
243
+ 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.",
244
+ inputSchema: {
245
+ proveedor: z.enum(["ml", "tiendanube", "tiendanegocio"]).describe("ml = Mercado Libre · tiendanube · tiendanegocio"),
246
+ 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)."),
247
+ },
248
+ }, async ({ proveedor, cliente }) => run(() => api("GET", `/${proveedor}/auth${q({ cliente })}`)));
249
+ }
250
+
251
+ // ============================================================================
252
+ // ESCRITURA (opt-in por flag · NUNCA dinero). Además gateado por permiso en el backend.
253
+ // ============================================================================
254
+ if (ALLOW_WRITE) {
255
+ if (puede("gestion")) {
256
+ tool("cliente_crear", {
257
+ title: "Crear cliente / vendedor",
258
+ 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.",
259
+ inputSchema: {
260
+ nombre: z.string().min(1).describe("Nombre del cliente"),
261
+ telefono: z.string().optional(),
262
+ dni: z.string().optional(),
263
+ direccion: z.string().optional(),
264
+ idLista: z.string().optional().describe("ID de la lista de precios a asignar (ej. 'B'). Consultá 'clientes_del_nodo'."),
265
+ },
266
+ }, async (args) => run(() => api("POST", "/gestion/clientes", args)));
267
+
268
+ tool("cliente_editar", {
269
+ title: "Editar cliente (incluye cambiar su lista)",
270
+ 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.",
271
+ inputSchema: {
272
+ id: z.number().int().positive().describe("ID del cliente"),
273
+ nombre: z.string().min(1).describe("Nombre actual del cliente (obligatorio)"),
274
+ idLista: z.string().optional().describe("Nueva lista de precio a asignar"),
275
+ telefono: z.string().optional(),
276
+ dni: z.string().optional(),
277
+ direccion: z.string().optional(),
278
+ },
279
+ }, async ({ id, ...body }) => run(() => api("PUT", `/gestion/clientes/${id}`, body)));
280
+ }
281
+
282
+ if (puede("wms") && wmsActivo) {
283
+ tool("producto_crear", {
284
+ title: "Crear producto (WMS)",
285
+ description: "Alta de un producto en el catálogo del depósito. Staff puede indicar el cliente dueño con idCliente. No toca dinero.",
286
+ inputSchema: {
287
+ nombre: z.string().min(1),
288
+ sku: z.string().optional(),
289
+ codigoBarra: z.string().optional(),
290
+ peso: z.number().optional(),
291
+ volumen: z.number().optional(),
292
+ idCliente: z.string().optional().describe("idCliente dueño del producto"),
293
+ },
294
+ }, async (args) => run(() => api("POST", "/wms/productos", args)));
295
+ }
296
+
297
+ if (esGlobal) {
298
+ tool("nodo_crear", {
299
+ title: "Crear nodo (logística)",
300
+ description: "Da de alta un nodo/logística nuevo. Solo admin global. No toca dinero.",
301
+ inputSchema: {
302
+ nombre: z.string().min(1).describe("Nombre del nodo/logística"),
303
+ telefono: z.string().optional(),
304
+ },
305
+ }, async ({ nombre, telefono }) => run(() => api("POST", "/logisticas", { nombre, telefono: telefono ?? null })));
306
+ }
307
+ }
308
+
309
+ // ============================================================================
310
+ // EDICIÓN DE PRECIOS (flag aparte · sensible pero NO mueve dinero).
311
+ // ============================================================================
312
+ if (ALLOW_PRECIOS && puede("precios")) {
313
+ tool("precio_actualizar", {
314
+ title: "Actualizar precio de una lista (versionado)",
315
+ 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.",
316
+ inputSchema: {
317
+ idLista: z.string().min(1).describe("ID de la lista (ej. 'B'). Ver 'precios_ver'."),
318
+ cercana: z.number().optional().describe("Precio zona cercana"),
319
+ media: z.number().optional().describe("Precio zona media"),
320
+ lejana: z.number().optional().describe("Precio zona lejana"),
321
+ muyLejana: z.number().optional().describe("Precio zona muy lejana"),
322
+ referencia: z.string().optional(),
323
+ vigenciaDesde: z.string().optional().describe("YYYY-MM-DD desde cuándo rige (default: hoy)"),
324
+ },
325
+ }, async (args) => run(() => api("POST", "/precios/clientes/version", args)));
326
+ }
327
+
328
+ const transport = new StdioServerTransport();
329
+ await server.connect(transport);
330
+ const cap = isCliente ? "cliente" : esGlobal ? "admin global" : isStaff ? "staff de nodo" : "sin identidad";
331
+ log(`MCP Nexus Flex v3 listo. Rol: ${cap}. Escritura: ${ALLOW_WRITE ? "ON" : "off"} · Precios: ${ALLOW_PRECIOS ? "ON" : "off"}.`);