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/device-auth.mjs CHANGED
@@ -1,132 +1,132 @@
1
- // ============================================================================
2
- // Device-flow del MCP (autorización web, estilo OAuth device code) + guardado
3
- // local del token. El MCP NO pide email/contraseña: pide un código, el usuario
4
- // lo autoriza desde el navegador (ya logueado en Nexus Flex) y el MCP recibe un
5
- // token de vida larga, revocable y con el scope exacto del usuario.
6
- // ============================================================================
7
- import fs from "fs";
8
- import os from "os";
9
- import path from "path";
10
- import { spawn, execFileSync } from "child_process";
11
-
12
- /** Ruta del token guardado. Override con NEXUSFLEX_MCP_TOKEN_FILE. */
13
- export function tokenFilePath() {
14
- if (process.env.NEXUSFLEX_MCP_TOKEN_FILE) return process.env.NEXUSFLEX_MCP_TOKEN_FILE;
15
- return path.join(os.homedir(), ".nexusflex-mcp", "token.json");
16
- }
17
-
18
- /** Etiqueta legible para el token (aparece en "Conexiones" de la web). */
19
- export function defaultClientName() {
20
- return process.env.NEXUSFLEX_MCP_CLIENT_NAME || `Claude MCP (${os.hostname?.() || "equipo"})`;
21
- }
22
-
23
- /** Restringe el archivo del token al usuario actual (privado). En POSIX: chmod 600.
24
- * En Windows chmod es no-op → usamos icacls para dejar SOLO al usuario actual. */
25
- function restringirPermisos(file) {
26
- if (process.platform === "win32") {
27
- try {
28
- const user = process.env.USERNAME || process.env.USER;
29
- // Corta la herencia y elimina a todos, dejando control total solo al usuario.
30
- execFileSync("icacls", [file, "/inheritance:r", "/grant:r", `${user}:F`], { stdio: "ignore" });
31
- } catch {
32
- console.error("[nexusflex-mcp] ADVERTENCIA: no se pudieron restringir los permisos del token en Windows. Usá una cuenta de un solo usuario.");
33
- }
34
- return;
35
- }
36
- try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
37
- }
38
-
39
- /** Guarda el token junto con la API a la que pertenece (no reusar entre servidores). */
40
- export function saveToken(token, apiUrl) {
41
- const file = tokenFilePath();
42
- fs.mkdirSync(path.dirname(file), { recursive: true });
43
- fs.writeFileSync(file, JSON.stringify({ token, apiUrl, savedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
44
- restringirPermisos(file);
45
- return file;
46
- }
47
-
48
- /** Lee el token guardado SI corresponde a esta misma API. */
49
- export function loadToken(apiUrl) {
50
- try {
51
- const file = tokenFilePath();
52
- // Aviso si en POSIX el archivo quedó legible por grupo/otros (permisos flojos).
53
- if (process.platform !== "win32") {
54
- try {
55
- const mode = fs.statSync(file).mode;
56
- if ((mode & 0o077) !== 0) console.error("[nexusflex-mcp] ADVERTENCIA: el archivo de token tiene permisos demasiado abiertos.");
57
- } catch { /* ignora */ }
58
- }
59
- const raw = JSON.parse(fs.readFileSync(file, "utf8"));
60
- if (raw?.token && raw?.apiUrl === apiUrl) return raw.token;
61
- } catch { /* no hay archivo o está corrupto */ }
62
- return null;
63
- }
64
-
65
- /** Borra el token guardado (logout local). */
66
- export function clearToken() {
67
- try { fs.rmSync(tokenFilePath()); return true; } catch { return false; }
68
- }
69
-
70
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
71
-
72
- /** Intenta abrir el navegador en la URL (best-effort, no falla si no puede). */
73
- function abrirNavegador(url) {
74
- try {
75
- const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
76
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
77
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
78
- child.on("error", () => {});
79
- child.unref?.();
80
- } catch { /* sin navegador disponible */ }
81
- }
82
-
83
- /**
84
- * Corre el device-flow completo contra la API: pide el código, muestra la URL +
85
- * user_code por stderr (y abre el navegador si `open`), poll-ea hasta que el
86
- * usuario autoriza y devuelve el token. Lanza si el usuario rechaza o vence.
87
- */
88
- export async function runDeviceFlow(apiUrl, { open = true, log = (...a) => console.error(...a), timeoutMs } = {}) {
89
- const clientName = defaultClientName();
90
- const startRes = await fetch(`${apiUrl}/mcp/device/start`, {
91
- method: "POST",
92
- headers: { "Content-Type": "application/json" },
93
- body: JSON.stringify({ client_name: clientName }),
94
- });
95
- if (!startRes.ok) throw new Error(`No se pudo iniciar la autorización (${startRes.status}).`);
96
- const s = await startRes.json();
97
- const { device_code, user_code, verification_uri, verification_uri_complete } = s;
98
- let interval = Math.max(2, Number(s.interval) || 5);
99
- const deadline = Date.now() + (timeoutMs ?? (Number(s.expires_in) || 600) * 1000);
100
-
101
- log("");
102
- log("┌───────────────────────────────────────────────────────────┐");
103
- log("│ 🔌 Conectar Nexus Flex — autorizá el acceso del asistente │");
104
- log("└───────────────────────────────────────────────────────────┘");
105
- log(` 1) Abrí: ${verification_uri}`);
106
- log(` 2) Código: ${user_code}`);
107
- log(` (o directo: ${verification_uri_complete})`);
108
- log("");
109
- log(" Esperando tu autorización en el navegador…");
110
- if (open) abrirNavegador(verification_uri_complete);
111
-
112
- while (Date.now() < deadline) {
113
- await sleep(interval * 1000);
114
- const r = await fetch(`${apiUrl}/mcp/device/token`, {
115
- method: "POST",
116
- headers: { "Content-Type": "application/json" },
117
- body: JSON.stringify({ device_code }),
118
- });
119
- const data = await r.json().catch(() => ({}));
120
- if (r.ok && data.access_token) {
121
- log(" ✅ ¡Autorizado! Conexión lista.");
122
- return data.access_token;
123
- }
124
- if (data.error === "authorization_pending") continue;
125
- if (data.error === "slow_down") { interval += 2; continue; }
126
- if (data.error === "access_denied") throw new Error("Rechazaste el acceso desde el navegador.");
127
- if (data.error === "expired_token") throw new Error("El código venció. Volvé a intentar.");
128
- // invalid_grant u otro: cortamos
129
- throw new Error(`No se pudo completar la autorización (${data.error || r.status}).`);
130
- }
131
- throw new Error("Se agotó el tiempo de espera de la autorización.");
132
- }
1
+ // ============================================================================
2
+ // Device-flow del MCP (autorización web, estilo OAuth device code) + guardado
3
+ // local del token. El MCP NO pide email/contraseña: pide un código, el usuario
4
+ // lo autoriza desde el navegador (ya logueado en Nexus Flex) y el MCP recibe un
5
+ // token de vida larga, revocable y con el scope exacto del usuario.
6
+ // ============================================================================
7
+ import fs from "fs";
8
+ import os from "os";
9
+ import path from "path";
10
+ import { spawn, execFileSync } from "child_process";
11
+
12
+ /** Ruta del token guardado. Override con NEXUSFLEX_MCP_TOKEN_FILE. */
13
+ export function tokenFilePath() {
14
+ if (process.env.NEXUSFLEX_MCP_TOKEN_FILE) return process.env.NEXUSFLEX_MCP_TOKEN_FILE;
15
+ return path.join(os.homedir(), ".nexusflex-mcp", "token.json");
16
+ }
17
+
18
+ /** Etiqueta legible para el token (aparece en "Conexiones" de la web). */
19
+ export function defaultClientName() {
20
+ return process.env.NEXUSFLEX_MCP_CLIENT_NAME || `Claude MCP (${os.hostname?.() || "equipo"})`;
21
+ }
22
+
23
+ /** Restringe el archivo del token al usuario actual (privado). En POSIX: chmod 600.
24
+ * En Windows chmod es no-op → usamos icacls para dejar SOLO al usuario actual. */
25
+ function restringirPermisos(file) {
26
+ if (process.platform === "win32") {
27
+ try {
28
+ const user = process.env.USERNAME || process.env.USER;
29
+ // Corta la herencia y elimina a todos, dejando control total solo al usuario.
30
+ execFileSync("icacls", [file, "/inheritance:r", "/grant:r", `${user}:F`], { stdio: "ignore" });
31
+ } catch {
32
+ console.error("[nexusflex-mcp] ADVERTENCIA: no se pudieron restringir los permisos del token en Windows. Usá una cuenta de un solo usuario.");
33
+ }
34
+ return;
35
+ }
36
+ try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
37
+ }
38
+
39
+ /** Guarda el token junto con la API a la que pertenece (no reusar entre servidores). */
40
+ export function saveToken(token, apiUrl) {
41
+ const file = tokenFilePath();
42
+ fs.mkdirSync(path.dirname(file), { recursive: true });
43
+ fs.writeFileSync(file, JSON.stringify({ token, apiUrl, savedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
44
+ restringirPermisos(file);
45
+ return file;
46
+ }
47
+
48
+ /** Lee el token guardado SI corresponde a esta misma API. */
49
+ export function loadToken(apiUrl) {
50
+ try {
51
+ const file = tokenFilePath();
52
+ // Aviso si en POSIX el archivo quedó legible por grupo/otros (permisos flojos).
53
+ if (process.platform !== "win32") {
54
+ try {
55
+ const mode = fs.statSync(file).mode;
56
+ if ((mode & 0o077) !== 0) console.error("[nexusflex-mcp] ADVERTENCIA: el archivo de token tiene permisos demasiado abiertos.");
57
+ } catch { /* ignora */ }
58
+ }
59
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
60
+ if (raw?.token && raw?.apiUrl === apiUrl) return raw.token;
61
+ } catch { /* no hay archivo o está corrupto */ }
62
+ return null;
63
+ }
64
+
65
+ /** Borra el token guardado (logout local). */
66
+ export function clearToken() {
67
+ try { fs.rmSync(tokenFilePath()); return true; } catch { return false; }
68
+ }
69
+
70
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
71
+
72
+ /** Intenta abrir el navegador en la URL (best-effort, no falla si no puede). */
73
+ function abrirNavegador(url) {
74
+ try {
75
+ const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
76
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
77
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
78
+ child.on("error", () => {});
79
+ child.unref?.();
80
+ } catch { /* sin navegador disponible */ }
81
+ }
82
+
83
+ /**
84
+ * Corre el device-flow completo contra la API: pide el código, muestra la URL +
85
+ * user_code por stderr (y abre el navegador si `open`), poll-ea hasta que el
86
+ * usuario autoriza y devuelve el token. Lanza si el usuario rechaza o vence.
87
+ */
88
+ export async function runDeviceFlow(apiUrl, { open = true, log = (...a) => console.error(...a), timeoutMs } = {}) {
89
+ const clientName = defaultClientName();
90
+ const startRes = await fetch(`${apiUrl}/mcp/device/start`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ client_name: clientName }),
94
+ });
95
+ if (!startRes.ok) throw new Error(`No se pudo iniciar la autorización (${startRes.status}).`);
96
+ const s = await startRes.json();
97
+ const { device_code, user_code, verification_uri, verification_uri_complete } = s;
98
+ let interval = Math.max(2, Number(s.interval) || 5);
99
+ const deadline = Date.now() + (timeoutMs ?? (Number(s.expires_in) || 600) * 1000);
100
+
101
+ log("");
102
+ log("┌───────────────────────────────────────────────────────────┐");
103
+ log("│ 🔌 Conectar Nexus Flex — autorizá el acceso del asistente │");
104
+ log("└───────────────────────────────────────────────────────────┘");
105
+ log(` 1) Abrí: ${verification_uri}`);
106
+ log(` 2) Código: ${user_code}`);
107
+ log(` (o directo: ${verification_uri_complete})`);
108
+ log("");
109
+ log(" Esperando tu autorización en el navegador…");
110
+ if (open) abrirNavegador(verification_uri_complete);
111
+
112
+ while (Date.now() < deadline) {
113
+ await sleep(interval * 1000);
114
+ const r = await fetch(`${apiUrl}/mcp/device/token`, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({ device_code }),
118
+ });
119
+ const data = await r.json().catch(() => ({}));
120
+ if (r.ok && data.access_token) {
121
+ log(" ✅ ¡Autorizado! Conexión lista.");
122
+ return data.access_token;
123
+ }
124
+ if (data.error === "authorization_pending") continue;
125
+ if (data.error === "slow_down") { interval += 2; continue; }
126
+ if (data.error === "access_denied") throw new Error("Rechazaste el acceso desde el navegador.");
127
+ if (data.error === "expired_token") throw new Error("El código venció. Volvé a intentar.");
128
+ // invalid_grant u otro: cortamos
129
+ throw new Error(`No se pudo completar la autorización (${data.error || r.status}).`);
130
+ }
131
+ throw new Error("Se agotó el tiempo de espera de la autorización.");
132
+ }
package/docs.mjs ADDED
@@ -0,0 +1,322 @@
1
+ // ============================================================================
2
+ // Documentación integrada del MCP de Nexus Flex — fuente de verdad de la ayuda
3
+ // por herramienta (tool `ayuda`). Cada tool trae: qué hace, cómo usarlo (params),
4
+ // un ejemplo resuelto y qué NO hace (para no inferir comportamiento inexistente).
5
+ //
6
+ // ⚠️ ESPEJO: este archivo está DUPLICADO en `backend/src/services/mcp-docs.ts`
7
+ // (el conector remoto se deploya sin la carpeta mcp/). Si editás uno, editá el
8
+ // otro — igual que MCP_VERSION/NOVEDADES. Mantené el contenido idéntico.
9
+ // ============================================================================
10
+
11
+ // Tópicos transversales (no son tools) — se consultan con `ayuda tema:<clave>`.
12
+ export const TOPICS = {
13
+ facturacion_marketplace: [
14
+ "FACTURACIÓN DEL MARKETPLACE DE RUTAS PÚBLICAS",
15
+ "",
16
+ "Adjudicar una ruta (ruta_adjudicar) es OPERATIVO: cierra la subasta, marca el",
17
+ "nodo ganador (nodoToma) y deja registrada la OBLIGACIÓN al precio de la oferta",
18
+ "(precioAcordado). NO crea ningún asiento automático de facturación semanal, ni",
19
+ "en rendiciones, ni en comisiones de afiliado, ni en el clearing entre nodos.",
20
+ "El marketplace NO mueve dinero.",
21
+ "",
22
+ "Es decir: adjudicar ≠ colecta. Una colecta entre nodos sí genera un ítem de",
23
+ "clearing; una ruta adjudicada, NO. El pago entre el nodo que publica y el que",
24
+ "toma se acuerda y se salda MANUALMENTE (efectivo con el cadete, o descontándolo",
25
+ "de lo que un nodo ya le debe al otro por otros conceptos).",
26
+ "",
27
+ "Cómo verificarlo desde el MCP:",
28
+ " • mis_rutas → estado 'adjudicada', nodoToma y precioAcordado de cada ruta tuya.",
29
+ " • rendiciones / comisiones_afiliado_ver → NO listarán la ruta adjudicada.",
30
+ "",
31
+ "Qué falta (roadmap, todavía NO implementado): un settlement automático que,",
32
+ "al adjudicar, cargue el precioAcordado en la cuenta corriente entre los dos",
33
+ "nodos. Mientras no exista, tratá el pago como manual y avisáselo al usuario.",
34
+ ].join("\n"),
35
+
36
+ dinero: [
37
+ "DINERO — QUÉ NO HACE NUNCA EL MCP",
38
+ "",
39
+ "El MCP nunca mueve plata: no registra pagos de vendedores, no factura, no",
40
+ "concilia caja, no imputa cobros/haberes en cuentas corrientes. Hay una denylist",
41
+ "de dinero (mcpMoneyGuard) heredada del backend. Las únicas escrituras 'con",
42
+ "olor a plata' son configuraciones de TARIFA (colecta_configurar, precio_",
43
+ "actualizar, afiliacion_crear) o el REGISTRO de una obligación (comision_liquidar",
44
+ "marca comisiones ya devengadas como pagadas; ruta_adjudicar deja el precio",
45
+ "acordado). Ver tema facturacion_marketplace.",
46
+ ].join("\n"),
47
+
48
+ aislamiento: [
49
+ "AISLAMIENTO ENTRE NODOS",
50
+ "",
51
+ "Cada token de MCP pertenece a un usuario. Todos los tools llaman a la API REST",
52
+ "del backend con ese token, así que heredan el MISMO gateo que la web:",
53
+ "requireAuth + requirePermiso + scope por nodo/idCliente. Un vendedor solo ve",
54
+ "SUS envíos/stock; un operador solo su nodo; el admin global ve la red. Ningún",
55
+ "tool puede leer ni escribir datos de otro nodo (salvo lo que es público por",
56
+ "diseño, como el marketplace de rutas abiertas). Si un tool devuelve 403 es el",
57
+ "aislamiento del backend, no un bug.",
58
+ ].join("\n"),
59
+ };
60
+
61
+ // Guías de arranque por rol (onboarding). Se muestran al conectar (server
62
+ // `instructions`) y con el tool `guia`. Metodología para empezar a usar el sistema
63
+ // rápido y bien, con las herramientas concretas de cada paso.
64
+ export const GUIAS = {
65
+ mensajero: [
66
+ "GUÍA DEL MENSAJERO — registrar lo que colectás, en el momento",
67
+ "",
68
+ "La idea: cuando pasás a colectar a un cliente, en vez de anotar a mano, me",
69
+ "mandás las FOTOS y yo cargo los envíos por vos. Paso a paso:",
70
+ "",
71
+ "1) Sacá una FOTO de cada paquete que agarrás: la etiqueta (Flex/Mercado Libre",
72
+ " o la que sea) y, si tiene QR, que se vea. Si el paquete NO tiene etiqueta,",
73
+ " sacá foto igual (datos del paquete: a quién va, dirección, teléfono).",
74
+ "2) Mandámelas todas juntas (un grupo de fotos). Yo leo cada una y registro el",
75
+ " envío del cliente que estás colectando:",
76
+ " • Con etiqueta ML / QR → lo registro con `envio_desde_etiqueta_ml`",
77
+ " (guardo el QR real, la etiqueta queda reimprimible).",
78
+ " • Sin etiqueta, con datos → lo registro con `envio_cargar` como pedido",
79
+ " manual y se le puede generar una etiqueta NUEVA para pegar.",
80
+ "3) IMPORTANTE (aislamiento): SOLO podés registrar envíos de clientes de TU",
81
+ " nodo, o de un cliente que tengas COLECTADO (incluye colectas tomadas del",
82
+ " marketplace de colectas). De otros clientes, no.",
83
+ "4) Cada envío queda 'A retirar', atribuido a vos, con un LINK de etiqueta",
84
+ " imprimible. El vendedor lo ve al toque en su cuenta.",
85
+ "5) Al llegar al nodo, el centro procesa los envíos; a los que cargaste sin",
86
+ " etiqueta se les pega una y salen normal. Después se escanean, aparecen en",
87
+ " el mapa y se te suma la tarifa como cualquier reparto.",
88
+ "",
89
+ "Tips: si una etiqueta se rompió o no se lee, cargá el envío con `envio_cargar`",
90
+ "y generás una nueva. Consultá el estado de un envío con `envio_consultar`, tu",
91
+ "ruta con `mi_ruta` y tus colectas con `mis_colectas`.",
92
+ "(Próximamente: aviso en la app para imprimir de una todas las etiquetas que",
93
+ "cargaste por acá.)",
94
+ ].join("\n"),
95
+
96
+ cliente: [
97
+ "GUÍA DEL VENDEDOR — cargá tus ventas y seguí tu operación desde acá",
98
+ "",
99
+ "1) Cargar una venta/envío: `envio_cargar` con destinatario, teléfono,",
100
+ " dirección y localidad (montoCobro si cobrás contra entrega). Te devuelvo",
101
+ " el tracking + un link de etiqueta para imprimir + un link para subir una",
102
+ " foto del paquete.",
103
+ "2) ¿Vendés por Mercado Libre? Mandame la foto de la etiqueta y la registro",
104
+ " con `envio_desde_etiqueta_ml` (queda con el QR real).",
105
+ "3) Colecta (que te retiren): `mi_colecta` para ver cómo estás, `colecta_auto`",
106
+ " para prender/apagar la automática, `colecta_solicitar` para una por única vez.",
107
+ "4) Tu negocio: `mi_stock` / `mi_disponible` (para no sobrevender), ",
108
+ " `mi_rentabilidad`, `mis_top_productos`, `mis_kpis`. Sucursales de retiro con",
109
+ " `mis_sucursales` / `sucursal_guardar`.",
110
+ "",
111
+ "Todo lo tuyo es solo tuyo (aislamiento). Nunca toco dinero por acá.",
112
+ ].join("\n"),
113
+
114
+ nodo: [
115
+ "GUÍA DEL NODO (operador/admin) — implementar y operar Nexus Flex rápido",
116
+ "",
117
+ "Puesta a punto:",
118
+ " • Vendedores: `cliente_crear` / `cliente_editar` (asigná su lista de precio).",
119
+ " • Choferes: `chofer_crear` (te devuelvo una clave temporal para pasarle).",
120
+ " • Colecta: `colecta_configurar` (pago del nodo/mensajero, cobro por vendedor).",
121
+ " • Zonas: `asignar_mensajero_zona` (mensajero↔metazona), `asignar_nodo_zona`",
122
+ " (un nodo de tu grupo cubre una localidad).",
123
+ "",
124
+ "El día a día:",
125
+ " • `colecta_pendientes` (hoy + mañana) y `colecta_asignar` (que Fulano levante",
126
+ " a tal cliente).",
127
+ " • Envíos: los cadetes pueden cargar lo que colectan por MCP (foto→envío); vos",
128
+ " los ves 'A retirar'. Consultá con `envio_consultar`, corregí con",
129
+ " `cobro_corregir` / `envio_reasignar_cliente`.",
130
+ " • Rendiciones: `rendiciones` (qué falta recuperar/rendir).",
131
+ "",
132
+ "Clearing entre nodos (eficiente):",
133
+ " • Metazonas + grupos logísticos rutean los envíos al nodo que cubre la zona.",
134
+ " Recibís lo que te rutean con `envios_por_zona` → `procesar_zona` (aceptar) o",
135
+ " `rechazar_zona`. El clearing sale de la config de tarifas por grupo.",
136
+ " • Marketplace de rutas (`rutas_publicas`, `ruta_publicar`/`ofertar`/`adjudicar`):",
137
+ " subasta abierta entre nodos. OJO: adjudicar NO factura automático — el pago",
138
+ " entre nodos se salda a mano (ver `ayuda tema:facturacion_marketplace`).",
139
+ " • Números: `kpi_nodo` (P&L, top clientes), `metricas_por_tipo`.",
140
+ "",
141
+ "Aislamiento: todo lo que ves/hacés es de TU nodo (o tu grupo cuando corresponde).",
142
+ ].join("\n"),
143
+ };
144
+
145
+ // Devuelve la guía de arranque para un rol (mensajero/cliente/operador/admin).
146
+ export function guiaOnboarding(rol) {
147
+ if (rol === "mensajero") return GUIAS.mensajero;
148
+ if (rol === "cliente") return GUIAS.cliente;
149
+ if (rol === "operador" || rol === "admin") return GUIAS.nodo;
150
+ return [GUIAS.cliente, "", "— — —", "", GUIAS.nodo].join("\n");
151
+ }
152
+
153
+ // Orden y agrupación para el índice (solo se muestran los que el usuario tiene).
154
+ export const GROUPS = [
155
+ { titulo: "General", tools: ["mis_datos", "guia", "ayuda", "mcp_version", "sugerencia_crear"] },
156
+ { titulo: "Vendedor — mi operación", tools: ["mis_envios", "envio_consultar", "mis_sucursales", "sucursal_guardar", "mi_colecta", "colecta_solicitar", "colecta_auto"] },
157
+ { titulo: "Vendedor — mi stock y números", tools: ["mi_stock", "mi_disponible", "mi_rentabilidad", "mis_productos", "mis_top_productos", "mis_kpis"] },
158
+ { titulo: "Mensajero", tools: ["mi_ruta", "mis_colectas"] },
159
+ { titulo: "Envíos (staff)", tools: ["envio_cargar", "envio_desde_etiqueta_ml", "envio_reasignar_cliente", "cobro_corregir"] },
160
+ { titulo: "Colecta y zonas (staff)", tools: ["colecta_ver", "colecta_pendientes", "colecta_configurar", "colecta_asignar", "colecta_desasignar", "zonas_reparto", "asignar_mensajero_zona", "asignar_nodo_zona", "envios_por_zona", "procesar_zona", "rechazar_zona"] },
161
+ { titulo: "Rendiciones y reclamos", tools: ["rendiciones", "rendicion_revertir", "reclamos_listar"] },
162
+ { titulo: "Marketplace de rutas", tools: ["rutas_publicas", "mis_rutas", "ruta_ofertas", "ruta_publicar", "ruta_ofertar", "ruta_adjudicar"] },
163
+ { titulo: "Afiliados", tools: ["afiliacion_listar", "comisiones_afiliado_ver", "afiliado_crear", "afiliacion_crear", "afiliacion_editar", "comision_liquidar"] },
164
+ { titulo: "Métricas / KPIs", tools: ["kpi_nodo", "kpi_red", "metricas_por_tipo", "top_productos"] },
165
+ { titulo: "WMS del nodo", tools: ["stock_nodo", "productos_nodo", "producto_crear"] },
166
+ { titulo: "Gestión del nodo", tools: ["clientes_del_nodo", "cliente_crear", "cliente_editar", "precios_ver", "precio_actualizar", "cuentas_vinculadas", "generar_enlace_vinculacion"] },
167
+ { titulo: "Usuarios / choferes", tools: ["chofer_listar", "chofer_crear", "chofer_editar", "usuario_habilitar_reparto"] },
168
+ { titulo: "Administración (red)", tools: ["nodos_listar", "nodo_crear", "sugerencias_listar"] },
169
+ ];
170
+
171
+ // Doc por herramienta: que=qué hace, uso=cómo/params, ej=ejemplo resuelto, no=qué NO hace.
172
+ export const TOOL_DOCS = {
173
+ ayuda: { que: "Devuelve esta documentación: índice de herramientas o la ficha completa de una (qué hace, params, ejemplo, qué NO hace) y tópicos transversales.", uso: "Sin argumentos = índice. `tool:<nombre>` = ficha de esa herramienta. `tema:<clave>` = tópico (facturacion_marketplace, dinero, aislamiento).", ej: "ayuda tool:ruta_adjudicar → te explica cómo adjudicar y aclara que no mueve dinero.", no: "No ejecuta nada ni consulta datos; es solo lectura de documentación estática." },
174
+ guia: { que: "Guía de ARRANQUE para tu rol: la metodología para empezar a usar Nexus Flex rápido, con las herramientas de cada paso (mensajero: foto→envío al colectar; vendedor: cargar ventas; nodo: implementar + operar + clearing).", uso: "Sin argumentos = tu guía según tu rol. `rol:<mensajero|cliente|nodo>` para ver otra.", ej: "Recién conectás como mensajero → guia → te explico cómo registrar lo que colectás sacando fotos.", no: "No ejecuta acciones; es la metodología. Para el detalle de una herramienta usá `ayuda tool:<x>`." },
175
+ mis_datos: { que: "Tu usuario, rol y nodo/cliente: define tu ALCANCE (qué podés ver/hacer).", uso: "Sin parámetros.", ej: "Antes de operar, mis_datos → confirmás que sos operador del nodo 6 y qué permisos tenés.", no: "No lista otros usuarios ni cambia nada." },
176
+ mcp_version: { que: "Versión del MCP corriendo, por qué vía (remoto/npx) y las novedades recientes.", uso: "Sin parámetros.", ej: "El usuario pregunta 'tengo lo último' → mcp_version.", no: "No actualiza el MCP." },
177
+ sugerencia_crear: { que: "Registra una sugerencia/mejora/bug del usuario (queda para el equipo).", uso: "mensaje (obligatorio); categoria opcional (funcionalidad|mejora|bug|otro). Se adjunta tu usuario/rol/nodo solo.", ej: "sugerencia_crear mensaje:'Sumar recordatorio de colecta' categoria:mejora.", no: "No abre tickets externos ni notifica por mail." },
178
+
179
+ mis_envios: { que: "Tus envíos/paquetes (solo los tuyos).", uso: "estado opcional (filtra por estado).", ej: "mis_envios estado:'En camino' → los que están en reparto.", no: "No muestra envíos de otros vendedores; no los edita." },
180
+ envio_consultar: { que: "Busca UN envío por tracking/código y devuelve estado, historial, destino y datos de cobro/cambio.", uso: "codigo (tracking o código). Vendedor: entre SUS envíos; staff: dentro de su nodo. Si hay varios matches, pasá el tracking completo.", ej: "El vendedor pregunta '¿dónde está 44000...?' → envio_consultar codigo:'44000123'.", no: "No cambia el estado del envío; no busca fuera de tu alcance." },
181
+ mis_sucursales: { que: "Tus sucursales / puntos de retiro (la principal = tu dirección de retiro), con horario de corte y ventanas.", uso: "Sin parámetros.", ej: "mis_sucursales → confirmás desde dónde te colectan.", no: "No las edita (para eso, sucursal_guardar)." },
182
+ sucursal_guardar: { que: "Crea o edita una sucursal tuya (punto de retiro). Geocodifica la dirección sola.", uso: "id vacío = nueva; nombre obligatorio; principal:true la vuelve tu dirección de retiro; horarioCorte HH:MM y ventanas opcionales.", ej: "Cambiar tu retiro: sucursal_guardar id:<principal> direccion:'Av. Rivadavia 5000, CABA' principal:true.", no: "No mueve dinero; el detalle de piso/depto/timbre va aparte, no al geocoder." },
183
+ mi_colecta: { que: "Estado de tu colecta (retiro): si la automática está prendida, si pediste una por única vez y el aviso de costo.", uso: "Sin parámetros.", ej: "mi_colecta → ver si mañana te pasan a buscar.", no: "No la prende/apaga (colecta_auto) ni la solicita (colecta_solicitar)." },
184
+ colecta_solicitar: { que: "Pedís que te retiren los envíos POR ÚNICA VEZ, aunque tengas la automática apagada.", uso: "Sin parámetros. Devuelve el aviso de costo si aplica.", ej: "colecta_solicitar → aparecés en el panel de colecta del nodo.", no: "No prende la colecta automática permanente; no mueve dinero." },
185
+ colecta_auto: { que: "Prende (true) o apaga (false) tu colecta AUTOMÁTICA.", uso: "activa (bool).", ej: "colecta_auto activa:false → dejás de que te retiren; llevás vos al depósito.", no: "No agenda una colecta puntual (para eso colecta_solicitar)." },
186
+
187
+ mi_stock: { que: "Tu stock físico (solo tus productos).", uso: "Sin parámetros.", ej: "mi_stock → ver unidades por SKU.", no: "No muestra disponible-para-vender (usá mi_disponible)." },
188
+ mi_disponible: { que: "Disponible por SKU = físico − comprometido; marca ⚠️ si está bajo el mínimo.", uso: "Sin parámetros.", ej: "mi_disponible → cuántas unidades podés seguir vendiendo sin sobrevender.", no: "No repone stock." },
189
+ mi_rentabilidad: { que: "Margen por SKU (precio − flete real − COGS).", uso: "desde/hasta YYYY-MM-DD (default: mes en curso).", ej: "mi_rentabilidad desde:2026-08-01 → margen del mes.", no: "No incluye gastos fijos del negocio; es por SKU." },
190
+ mis_productos: { que: "Tu catálogo de productos.", uso: "Sin parámetros.", ej: "mis_productos → ver SKUs cargados.", no: "No da de alta (producto_crear, si el nodo tiene WMS)." },
191
+ mis_top_productos: { que: "Ranking de tus productos más despachados.", uso: "desde/hasta YYYY-MM-DD (default: mes).", ej: "mis_top_productos → qué se vende más.", no: "No muestra rentabilidad (usá mi_rentabilidad)." },
192
+ mis_kpis: { que: "Tus métricas + benchmark anónimo de tu nodo.", uso: "desde/hasta opcionales.", ej: "mis_kpis → tu tasa de entrega vs. el promedio del nodo.", no: "No revela datos de otros vendedores (el benchmark es anónimo)." },
193
+
194
+ mi_ruta: { que: "Tus entregas asignadas: las paradas de tu ruta del día.", uso: "Sin parámetros.", ej: "mi_ruta → orden de reparto de hoy.", no: "No marca entregas (eso se hace escaneando en la app)." },
195
+ mis_colectas: { que: "Las colectas/retiros que tenés asignados.", uso: "Sin parámetros.", ej: "mis_colectas → a qué vendedores tenés que ir a buscar.", no: "No las completa/confirma desde acá." },
196
+
197
+ envio_cargar: { que: "Registra un envío nuevo (queda 'A retirar') y devuelve tracking + link de etiqueta + link para subir foto.", uso: "Obligatorios: destinatario, telefono, direccion, localidad. Staff pasa `cliente` (nombre del vendedor de su nodo); el vendedor no. montoCobro = cobro contra entrega (opcional).", ej: "Staff: envio_cargar cliente:'Distri Sur' destinatario:'Ana' telefono:'11...' direccion:'Belgrano 100' localidad:'Lanús' montoCobro:15000.", no: "No mueve dinero (montoCobro es el cobro a destino, no un asiento); no imprime, devuelve el link." },
198
+ envio_desde_etiqueta_ml: { que: "Registra un envío a partir de lo que VOS (Claude) leíste de la foto de una etiqueta de Mercado Libre.", uso: "Leé la etiqueta: mlShipmentId + (si podés) mlQr crudo, mlSenderId del vendedor y el destino. El vendedor se mapea por mlSenderId (cuenta vinculada) o `cliente` por nombre. Una llamada por etiqueta.", ej: "envio_desde_etiqueta_ml mlSenderId:'123456' mlShipmentId:'44000...' destinatario:'Juan' direccion:'...' localidad:'Avellaneda'.", no: "No mueve dinero; si no reconoce el vendedor por mlSenderId tenés que pasar `cliente`." },
199
+ envio_reasignar_cliente: { que: "Mueve UN envío (por tracking) a otro cliente/vendedor de tu nodo cuando se cargó mal.", uso: "tracking + cliente (nombre o id destino, de tu nodo). Queda en el historial.", ej: "envio_reasignar_cliente tracking:'44000...' cliente:'Comercial Norte'.", no: "No cruza nodos; no cambia el estado del envío." },
200
+ cobro_corregir: { que: "Ajusta el monto a cobrar contra entrega de un envío (se cobró de más/de menos), guardando el original en el historial.", uso: "envioId + monto nuevo; motivo opcional.", ej: "cobro_corregir envioId:987 monto:12000 motivo:'lista vieja'.", no: "No mueve dinero en cuentas: corrige el DATO del envío." },
201
+
202
+ colecta_ver: { que: "Resumen de valores de colecta del nodo: pago default, cobros por cliente y pagos pactados por cliente+mensajero.", uso: "Sin parámetros.", ej: "colecta_ver → cuánto se paga/cobra por colecta.", no: "No cambia tarifas (colecta_configurar)." },
203
+ colecta_pendientes: { que: "Panel de colectas del nodo: `items` = a retirar HOY (por cliente, con corte y mensajero asignado); `itemsManana` = clientes cuyos envíos entraron después del corte → van a mañana.", uso: "Sin parámetros. Trae el colectaId para desasignar.", ej: "'¿Quién levanta a Distri Sur?' → colecta_pendientes y mirás items.", no: "No asigna (colecta_asignar) ni configura tarifas." },
204
+ colecta_configurar: { que: "Setea un valor de colecta según alcance.", uso: "alcance: 'nodo' (pago default por colecta), 'mensajero' (default de ese cadete), 'cliente' (cuánto se le COBRA a ese vendedor; idCliente + valor, minEnvios opcional), 'par' (pago pactado a un mensajero por un cliente puntual; idCliente + mensajero + valor). Scopeado a tu nodo.", ej: "colecta_configurar alcance:cliente idCliente:'130' valor:2500 minEnvios:5 → gratis desde 5 envíos, sino $2500.", no: "No mueve dinero: es config de tarifa (el cobro se aplica al liquidar)." },
205
+ colecta_asignar: { que: "Asigna la colecta de un cliente a un mensajero (ambos por nombre de tu nodo) y avisa al cadete.", uso: "cliente + mensajero (nombres). Si el corte venció, la programa para el próximo día hábil.", ej: "colecta_asignar cliente:'Distri Sur' mensajero:'Maxi'.", no: "No mueve dinero; no cruza nodos." },
206
+ colecta_desasignar: { que: "Quita la asignación de una colecta (los envíos vuelven a 'sin colecta').", uso: "colectaId (lo devuelve colecta_pendientes).", ej: "colecta_desasignar colectaId:44.", no: "No borra los envíos; no mueve dinero." },
207
+ zonas_reparto: { que: "Zonas de reparto del nodo con sus metazonas y qué mensajeros tiene cada una.", uso: "Sin parámetros.", ej: "zonas_reparto → ver quién cubre Palermo.", no: "No asigna (asignar_mensajero_zona / asignar_nodo_zona)." },
208
+ asignar_mensajero_zona: { que: "Asigna un mensajero de tu nodo a una metazona; si la zona existe le suma el cadete, si no la crea.", uso: "mensajero + metazona; nombre opcional.", ej: "asignar_mensajero_zona mensajero:'Maxi' metazona:'Palermo'.", no: "No cruza nodos." },
209
+ asignar_nodo_zona: { que: "Asigna un NODO COMPLETO (de tu grupo logístico) a una metazona, cuando ese nodo cubre toda la localidad.", uso: "nodo (nombre, debe compartir grupo logístico) + metazona; nombre opcional.", ej: "asignar_nodo_zona nodo:'RL' metazona:'Portela'.", no: "No suma nodos fuera de tu grupo logístico." },
210
+ envios_por_zona: { que: "Envíos que OTROS nodos te rutearon por zona de reparto, agrupados por zona (para aceptar/rechazar).", uso: "Sin parámetros. Cada envío trae su id (para procesar_zona/rechazar_zona).", ej: "envios_por_zona → ver qué te mandaron por la zona Sur.", no: "No los acepta solo: usá procesar_zona o rechazar_zona." },
211
+ procesar_zona: { que: "ACEPTA (recibe en tu nodo) envíos ruteados por zona.", uso: "envioIds (de envios_por_zona); mensajeroId opcional para asignarlos.", ej: "procesar_zona envioIds:[101,102] mensajeroId:7.", no: "No mueve dinero (el clearing es config)." },
212
+ rechazar_zona: { que: "RECHAZA envíos ruteados por zona: dejan de aparecerte y quedan para origen u otros nodos de la zona.", uso: "envioIds (de envios_por_zona); motivo opcional.", ej: "rechazar_zona envioIds:[103] motivo:'fuera de mi cobertura'.", no: "No cambia el estado del envío." },
213
+
214
+ rendiciones: { que: "Estado de rendiciones: cobros/cambios/devoluciones a recuperar o rendir, con totales y quién tiene cada uno.", uso: "Sin parámetros. Scopeado a tu alcance (vendedor: lo tuyo; nodo: tu nodo; mensajero: lo suyo).", ej: "rendiciones → cuánto falta que rinda cada cadete.", no: "No confirma ni revierte (rendicion_revertir); NO incluye rutas de marketplace adjudicadas (ver ayuda tema:facturacion_marketplace)." },
215
+ rendicion_revertir: { que: "Revierte un cobro/cambio marcado como 'rendido' por error → vuelve a 'a rendir'.", uso: "envioId + tipo (cobro|cambio); motivo opcional. Deja registro.", ej: "rendicion_revertir envioId:987 tipo:cobro motivo:'se marcó sin recibir'.", no: "No mueve dinero en cuentas; corrige el estado de la rendición." },
216
+ reclamos_listar: { que: "Reclamos abiertos de clientes ligados a liquidaciones/rendiciones (tipo, estado, trackings en disputa).", uso: "estado opcional (abierto|resuelto). Vendedor: lo suyo; operador: su nodo.", ej: "reclamos_listar estado:abierto → qué cobros están en disputa.", no: "No resuelve reclamos desde acá." },
217
+
218
+ rutas_publicas: { que: "Publicaciones ABIERTAS de toda la red que tu nodo puede tomar (con cuántas ofertas tiene cada una). Marca las tuyas con esMia.", uso: "Sin parámetros.", ej: "rutas_publicas → ver qué rutas hay para ofertar.", no: "No oferta (ruta_ofertar)." },
219
+ mis_rutas: { que: "Tus publicaciones (con estado/adjudicación: nodoToma y precioAcordado) y las ofertas que hiciste a otros.", uso: "Sin parámetros.", ej: "mis_rutas → ver si tu ruta se adjudicó y a quién.", no: "El precioAcordado es la obligación registrada, NO un asiento de facturación (ver ayuda tema:facturacion_marketplace)." },
220
+ ruta_ofertas: { que: "Ofertas recibidas en una publicación TUYA, de la más barata a la más cara. Solo el que publicó.", uso: "publicacionId. Los ids de oferta sirven para adjudicar.", ej: "ruta_ofertas publicacionId:12 → elegís la mejor y su ofertaId.", no: "No adjudica (ruta_adjudicar)." },
221
+ ruta_publicar: { que: "Publicás una ruta/colecta/viaje para que cualquier nodo la tome (subasta abierta). VOS le pagás al que la toma.", uso: "titulo (obligatorio); tipo (ruta|colecta|viaje), descripcion, zona, precioMax (tope que ofrecés pagar) opcionales.", ej: "ruta_publicar titulo:'Reparto zona Oeste 40 paquetes' tipo:ruta precioMax:20000.", no: "No mueve dinero: la obligación recién se registra al adjudicar; el pago es manual." },
222
+ ruta_ofertar: { que: "Ofertás por una publicación de OTRO nodo. precio = lo que cobrás por hacerla (más barato = mejor para el que publica).", uso: "publicacionId + precio; nota opcional. Si ya ofertaste, la actualiza.", ej: "ruta_ofertar publicacionId:12 precio:18000 nota:'salgo 8am'.", no: "No podés ofertar en tu propia publicación." },
223
+ ruta_adjudicar: { que: "Elegís la oferta ganadora de una publicación TUYA y cerrás la subasta: el nodo ganador la toma a su precio y queda registrada la obligación.", uso: "publicacionId + ofertaId (de ruta_ofertas).", ej: "ruta_adjudicar publicacionId:12 ofertaId:34 → gana el nodo de esa oferta a $18000.", no: "NO crea asiento de facturación/rendición/comisión ni clearing automático: el pago entre nodos se salda MANUALMENTE (ver ayuda tema:facturacion_marketplace)." },
224
+
225
+ afiliacion_listar: { que: "Afiliaciones (afiliado↔entidad referida) con su comisión por envío, vigencia y estado.", uso: "afiliado y/o entidad opcionales para filtrar. Scopeado a tu nodo.", ej: "afiliacion_listar afiliado:'Nodo RL'.", no: "No muestra comisiones devengadas (comisiones_afiliado_ver)." },
226
+ comisiones_afiliado_ver: { que: "Comisiones devengadas por envío (pendiente/liquidada) con totales.", uso: "afiliado, desde, hasta (YYYY-MM-DD) opcionales. Scopeado a tu nodo.", ej: "comisiones_afiliado_ver afiliado:'Nodo RL' desde:2026-08-01.", no: "No las paga (comision_liquidar); NO incluye rutas de marketplace." },
227
+ afiliado_crear: { que: "Alta de un afiliado (quien trae volumen nuevo a la red).", uso: "nombre + tipo (nodo|mensajero|externo); refId = id del nodo/mensajero (null si externo).", ej: "afiliado_crear nombre:'Juan Ref' tipo:externo.", no: "No crea la afiliación/comisión (afiliacion_crear); no toca dinero." },
228
+ afiliacion_crear: { que: "Vincula un afiliado con una entidad referida (nodo o cliente) y su comisión RECURRENTE por envío.", uso: "afiliado + entidadTipo (nodo|cliente) + entidad + tipoComision (porcentaje|montoFijo) + valorComision; fechaExpiracion opcional. Una entidad = un afiliado activo.", ej: "afiliacion_crear afiliado:'Juan Ref' entidadTipo:cliente entidad:'Distri Sur' tipoComision:montoFijo valorComision:50.", no: "No paga (comision_liquidar); es config, no mueve dinero." },
229
+ afiliacion_editar: { que: "Edita una afiliación: valorComision, fechaExpiracion (renovar/extender) y/o activa.", uso: "id + los campos a cambiar.", ej: "afiliacion_editar id:5 activa:false → la das de baja.", no: "No toca dinero." },
230
+ comision_liquidar: { que: "Marca comisiones devengadas como LIQUIDADAS (registra el pago al afiliado).", uso: "ids = lista de comisiones (de comisiones_afiliado_ver). Requiere permiso de finanzas. Scopeado a tu nodo.", ej: "comision_liquidar ids:[10,11,12].", no: "No calcula comisiones (se devengan solas por envío); no factura." },
231
+
232
+ kpi_nodo: { que: "Tablero del nodo: P&L, top clientes, caídas.", uso: "desde/hasta opcionales; nodo solo para admin.", ej: "kpi_nodo desde:2026-08-01 → resultado del mes.", no: "No baja a un vendedor puntual; solo tu nodo (salvo admin)." },
233
+ kpi_red: { que: "KPIs globales de la red.", uso: "Sin parámetros. Solo admin global.", ej: "kpi_red → panorama SaaS de toda la red.", no: "No desglosa por nodo (usá kpi_nodo con nodo)." },
234
+ metricas_por_tipo: { que: "Métricas por TIPO de envío (flex/tienda/manual): total, entregados, tasa y % entregado antes de las 21hs. Para admin desglosa por logística.", uso: "desde/hasta YYYY-MM-DD (default 30 días); nodo solo admin.", ej: "metricas_por_tipo → ver si los Flex se entregan a tiempo.", no: "No es por cliente." },
235
+ top_productos: { que: "Ranking de productos más despachados del nodo (WMS).", uso: "desde/hasta y cliente opcionales.", ej: "top_productos cliente:'Distri Sur'.", no: "Requiere WMS activo en el nodo." },
236
+
237
+ stock_nodo: { que: "Stock del depósito del nodo (WMS).", uso: "cliente opcional para filtrar.", ej: "stock_nodo cliente:'Distri Sur'.", no: "Requiere WMS activo; no repone." },
238
+ productos_nodo: { que: "Catálogo del depósito del nodo (WMS).", uso: "cliente opcional.", ej: "productos_nodo.", no: "Requiere WMS activo." },
239
+ producto_crear: { que: "Alta de producto en el depósito (WMS).", uso: "nombre + sku/codigoBarra/peso/volumen opcionales; staff pasa idCliente dueño.", ej: "producto_crear nombre:'Remera M' sku:'REM-M' idCliente:'130'.", no: "No mueve dinero; requiere WMS activo." },
240
+
241
+ clientes_del_nodo: { que: "Clientes/vendedores del nodo con su lista de precio.", uso: "Sin parámetros. Scopeado a tu nodo.", ej: "clientes_del_nodo → ver a quién facturás y con qué lista.", no: "No los crea/edita (cliente_crear/editar)." },
242
+ cliente_crear: { que: "Alta de cliente/vendedor en tu nodo.", uso: "nombre obligatorio; telefono/dni/direccion/idLista opcionales.", ej: "cliente_crear nombre:'Nueva Distri' idLista:'ID12'.", no: "No toca dinero; no cruza nodos." },
243
+ cliente_editar: { que: "Edita un cliente de tu nodo (p. ej. cambiar su lista de precio).", uso: "id + nombre (obligatorio) + los campos a cambiar (idLista, etc.).", ej: "cliente_editar id:130 nombre:'Distri Sur' idLista:'ID12'.", no: "No toca dinero." },
244
+ precios_ver: { que: "Listas de precios por zona de tu nodo.", uso: "Sin parámetros. Requiere permiso 'precios'.", ej: "precios_ver → ver tarifas por zona.", no: "No las edita (precio_actualizar)." },
245
+ precio_actualizar: { que: "Cambia precios de una lista creando una VERSIÓN nueva (histórico intacto).", uso: "idLista + los tramos (cercana/media/lejana/muyLejana); referencia/vigenciaDesde opcionales. Requiere permiso 'precios' (y que el conector lo permita).", ej: "precio_actualizar idLista:'ID12' cercana:1500 media:2000 lejana:2800.", no: "No mueve dinero; puede estar deshabilitado por el conector (ALLOW_PRECIOS)." },
246
+ cuentas_vinculadas: { que: "Cuántas cuentas de tienda hay vinculadas, por proveedor (ML / TiendaNube / TiendaNegocio).", uso: "nodo opcional (solo admin, baja al desglose por cliente).", ej: "cuentas_vinculadas → cuántos vendedores tienen ML conectado.", no: "No vincula (generar_enlace_vinculacion)." },
247
+ generar_enlace_vinculacion: { que: "Genera el enlace para vincular una tienda; se lo mandás al cliente para que autorice.", uso: "proveedor (ml|tiendanube|tiendanegocio); staff pasa `cliente` (de su nodo).", ej: "generar_enlace_vinculacion proveedor:ml cliente:'Distri Sur'.", no: "No completa la vinculación (la autoriza el dueño de la tienda); no toca dinero." },
248
+
249
+ chofer_listar: { que: "Usuarios de tu nodo, incluidos los choferes (rol mensajero): id, nombre, teléfono, activo.", uso: "Sin parámetros. Requiere permiso 'usuarios'.", ej: "chofer_listar → ver tus cadetes.", no: "No crea/edita (chofer_crear/editar)." },
250
+ chofer_crear: { que: "Da de alta un chofer (mensajero) en tu nodo con una CLAVE TEMPORAL que devuelve el server.", uso: "nombre + telefono + email; mensajeroNombre opcional (macheo con su cta cte). Pasale la clave; la cambia al primer ingreso.", ej: "chofer_crear nombre:'Maxi' telefono:'11...' email:'maxi@...'.", no: "Claude nunca inventa la clave; el server la genera." },
251
+ chofer_editar: { que: "Edita un chofer de tu nodo: nombre, teléfono, mensajeroNombre y/o activo.", uso: "id + los campos a cambiar.", ej: "chofer_editar id:7 activo:false → lo desactivás.", no: "No toca credenciales/clave." },
252
+ usuario_habilitar_reparto: { que: "Marca a un operador/admin de tu nodo como TAMBIÉN mensajero (puede escanear, autoasignarse y entregar).", uso: "id + activo (default true).", ej: "usuario_habilitar_reparto id:4 → ese comisionista ya puede repartir.", no: "No toca credenciales." },
253
+
254
+ nodos_listar: { que: "Todas las logísticas (nodos) de la red.", uso: "Sin parámetros. Solo admin global.", ej: "nodos_listar → id/nombre de cada nodo.", no: "No los crea (nodo_crear)." },
255
+ nodo_crear: { que: "Alta de una logística/nodo.", uso: "nombre + telefono opcional. Solo admin global.", ej: "nodo_crear nombre:'Nodo Oeste' telefono:'11...'.", no: "No toca dinero." },
256
+ sugerencias_listar: { que: "Sugerencias entrantes de usuarios (funciones/mejoras/bugs) con usuario, rol, nodo, estado.", uso: "rol y/o estado opcionales. Operador: su nodo; admin: todas.", ej: "sugerencias_listar estado:nueva.", no: "No las implementa; es lectura." },
257
+ };
258
+
259
+ // Renderiza la ayuda. q = nombre de tool o clave de tema (opcional).
260
+ // ctx = { version, disponibles: Set<string> } — disponibles filtra el índice a lo
261
+ // que ese usuario realmente tiene (según su rol/permisos).
262
+ export function renderAyuda(q, ctx = {}) {
263
+ const version = ctx.version ?? "";
264
+ const disponibles = ctx.disponibles instanceof Set ? ctx.disponibles : null;
265
+ const clave = String(q ?? "").trim().toLowerCase().replace(/[^a-z_]/g, "");
266
+
267
+ if (!clave) return indice(version, disponibles);
268
+
269
+ if (TOPICS[clave]) return TOPICS[clave] + `\n\n(Consultá una herramienta con: ayuda tool:<nombre>)`;
270
+
271
+ const d = TOOL_DOCS[clave];
272
+ if (d) {
273
+ const tiene = !disponibles || disponibles.has(clave);
274
+ const nota = tiene ? "" : "\n\n(⚠️ Con tu rol/permiso actual esta herramienta no está disponible.)";
275
+ return [
276
+ `🔧 ${clave}`,
277
+ "",
278
+ `Qué hace: ${d.que}`,
279
+ `Cómo usar: ${d.uso}`,
280
+ `Ejemplo: ${d.ej}`,
281
+ `Qué NO hace: ${d.no}`,
282
+ ].join("\n") + nota;
283
+ }
284
+
285
+ // No matcheó: sugerir por prefijo/substring.
286
+ const cerca = Object.keys(TOOL_DOCS).filter((n) => n.includes(clave)).slice(0, 8);
287
+ const temas = Object.keys(TOPICS);
288
+ return [
289
+ `No encontré una herramienta o tema llamado "${clave}".`,
290
+ cerca.length ? `¿Quisiste decir?: ${cerca.join(", ")}` : "",
291
+ `Temas disponibles: ${temas.join(", ")}`,
292
+ `Sin argumentos, ayuda te da el índice completo.`,
293
+ ].filter(Boolean).join("\n");
294
+ }
295
+
296
+ function indice(version, disponibles) {
297
+ const out = [
298
+ `AYUDA DEL MCP NEXUS FLEX${version ? ` (v${version})` : ""}`,
299
+ "",
300
+ "Pedí el detalle de una herramienta con: ayuda tool:<nombre>",
301
+ "Pedí un tema transversal con: ayuda tema:<clave>",
302
+ `Temas: ${Object.keys(TOPICS).join(", ")}`,
303
+ "",
304
+ "Herramientas" + (disponibles ? " disponibles para vos" : "") + ":",
305
+ ];
306
+ for (const g of GROUPS) {
307
+ const items = g.tools.filter((t) => TOOL_DOCS[t] && (!disponibles || disponibles.has(t)));
308
+ if (!items.length) continue;
309
+ out.push("", `▸ ${g.titulo}`);
310
+ for (const t of items) out.push(` • ${t} — ${TOOL_DOCS[t].que}`);
311
+ }
312
+ // Tools registradas que no estén en ningún grupo (por si se agrega una y se olvida).
313
+ if (disponibles) {
314
+ const enGrupos = new Set(GROUPS.flatMap((g) => g.tools));
315
+ const sueltas = [...disponibles].filter((t) => TOOL_DOCS[t] && !enGrupos.has(t));
316
+ if (sueltas.length) {
317
+ out.push("", "▸ Otras");
318
+ for (const t of sueltas) out.push(` • ${t} — ${TOOL_DOCS[t].que}`);
319
+ }
320
+ }
321
+ return out.join("\n");
322
+ }