nexusflex-mcp 3.0.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/INSTALAR.md +97 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/api.mjs +135 -0
- package/device-auth.mjs +132 -0
- package/package.json +29 -0
- package/server.mjs +315 -0
package/INSTALAR.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# MCP de Nexus Flex — Instalación (npx + autorización web)
|
|
2
|
+
|
|
3
|
+
Este paquete deja que un asistente de IA (**Claude Desktop** o **Claude Code**) opere
|
|
4
|
+
Nexus Flex **con tu cuenta**, en tu computadora, hablando con la API igual que la web.
|
|
5
|
+
|
|
6
|
+
> **Seguridad (aislamiento en 3 capas):** el MCP no puede hacer nada que tu usuario no
|
|
7
|
+
> pueda hacer desde la web. **1)** El backend gatea cada dato por rol y por nodo/cliente
|
|
8
|
+
> (el token hereda tu rol/permisos frescos en cada pedido). **2)** El MCP muestra SOLO
|
|
9
|
+
> los tools de tu rol. **3)** Nunca toca facturación/cobros/cuentas (bloqueado en el
|
|
10
|
+
> cliente **y** en el backend). Cada usuario ve **solo lo suyo**.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Requisitos
|
|
15
|
+
- **Node.js 20+** — https://nodejs.org (LTS). Verificá con `node --version`.
|
|
16
|
+
- **Claude Desktop** (https://claude.ai/download) o Claude Code.
|
|
17
|
+
- Una cuenta de Nexus Flex (la misma de la web). **No** hace falta tu contraseña acá:
|
|
18
|
+
autorizás desde el navegador.
|
|
19
|
+
|
|
20
|
+
## 2. Autorizar (una sola vez)
|
|
21
|
+
En una terminal, corré:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx -y nexusflex-mcp@latest login
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Se abre el navegador (o te muestra un link + un código de 8 letras). Entrás a Nexus Flex
|
|
28
|
+
—si no estás logueado, iniciás sesión— y hacés click en **Autorizar**. Listo: el token
|
|
29
|
+
queda guardado en tu equipo (`~/.nexusflex-mcp/token.json`).
|
|
30
|
+
|
|
31
|
+
## 3. Conectarlo a Claude Desktop
|
|
32
|
+
Editá el config de Claude Desktop:
|
|
33
|
+
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
34
|
+
- **Mac:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
35
|
+
|
|
36
|
+
Pegá esto (¡sin email ni contraseña!):
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"nexusflex": {
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["-y", "nexusflex-mcp@latest"],
|
|
44
|
+
"env": {
|
|
45
|
+
"NEXUSFLEX_MCP_ALLOW_WRITE": "true",
|
|
46
|
+
"NEXUSFLEX_MCP_ALLOW_PRECIOS": "false"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Guardá y **reiniciá Claude Desktop**. (Si nunca corriste el paso 2, la primera vez que
|
|
54
|
+
arranque el MCP te va a mostrar el link + código en sus logs para que autorices.)
|
|
55
|
+
|
|
56
|
+
- `NEXUSFLEX_MCP_ALLOW_WRITE` = `"true"` habilita **altas y ediciones** (clientes,
|
|
57
|
+
productos, y nodos si sos admin global). `"false"` = solo consulta.
|
|
58
|
+
- `NEXUSFLEX_MCP_ALLOW_PRECIOS` = `"true"` habilita **actualizar listas de precios**.
|
|
59
|
+
|
|
60
|
+
## 4. Qué ve cada usuario (según con qué cuenta autorices)
|
|
61
|
+
|
|
62
|
+
**CLIENTE (vendedor):** `mis_datos`, `mis_envios`, `mis_kpis`, `mi_stock`,
|
|
63
|
+
`mi_disponible` (disponible-para-vender por SKU), `mi_rentabilidad` (margen por SKU),
|
|
64
|
+
`mis_productos`, `mis_top_productos`. **No** ve datos del nodo ni de otros clientes.
|
|
65
|
+
|
|
66
|
+
**STAFF del nodo:** `clientes_del_nodo`, `precios_ver`, `kpi_nodo`, `stock_nodo`,
|
|
67
|
+
`productos_nodo`, `top_productos`, y —con escritura— `cliente_crear`, `cliente_editar`,
|
|
68
|
+
`producto_crear`, y —con precios— `precio_actualizar`. Todo scopeado a **su** nodo.
|
|
69
|
+
|
|
70
|
+
**Admin GLOBAL:** además `nodos_listar`, `kpi_red`, `nodo_crear`.
|
|
71
|
+
|
|
72
|
+
## 5. Ejemplos (lenguaje natural en Claude)
|
|
73
|
+
- *"¿Qué usuario y alcance tengo?"* → `mis_datos`.
|
|
74
|
+
- *"¿Cuánto tengo disponible para vender del SKU ABC?"* → `mi_disponible`.
|
|
75
|
+
- *"¿Qué productos me dejan más margen este mes?"* → `mi_rentabilidad`.
|
|
76
|
+
- *"Dame de alta a Distribuidora López con la lista B."* → `cliente_crear`.
|
|
77
|
+
- *"Actualizá la lista B: cercana 3800, media 4200."* → `precio_actualizar` (necesita ALLOW_PRECIOS).
|
|
78
|
+
|
|
79
|
+
## 6. Revocar el acceso
|
|
80
|
+
En la web de Nexus Flex, sección **🔌 Conexiones**, revocás cualquier asistente cuando
|
|
81
|
+
quieras. Local: `npx -y nexusflex-mcp@latest logout` borra el token de tu equipo.
|
|
82
|
+
|
|
83
|
+
## 7. Qué NO puede hacer — nunca
|
|
84
|
+
Facturar, registrar cobros/pagos, tocar cuentas corrientes, liquidaciones ni saldos.
|
|
85
|
+
Está bloqueado en el cliente **y** en el backend; aunque se lo pidas, lo rechaza.
|
|
86
|
+
|
|
87
|
+
## Alternativas de credenciales (avanzado / compatibilidad)
|
|
88
|
+
- `NEXUSFLEX_TOKEN` = un token ya emitido (MCP o JWT) → salta el device-flow.
|
|
89
|
+
- `NEXUSFLEX_EMAIL` + `NEXUSFLEX_PASSWORD` = login legado por contraseña.
|
|
90
|
+
- `NEXUSFLEX_API_URL` = base de la API (default `https://nexusflex.com.ar/api`).
|
|
91
|
+
- `NEXUSFLEX_MCP_NO_AUTH_PROMPT=true` = no abrir device-flow automáticamente (falla
|
|
92
|
+
pidiendo `login` explícito).
|
|
93
|
+
|
|
94
|
+
## Problemas comunes
|
|
95
|
+
- *"Sesión inválida"* (401) → corré `npx nexusflex-mcp login` de nuevo (te lo pudieron revocar).
|
|
96
|
+
- *"No tenés permiso"* (403) → tu usuario no tiene ese permiso (es correcto: el backend aísla).
|
|
97
|
+
- No aparecen las herramientas → cerrá Claude Desktop del todo y reabrí.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nexus Flex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# nexusflex-mcp
|
|
2
|
+
|
|
3
|
+
Servidor **MCP** (Model Context Protocol) de [Nexus Flex](https://nexusflex.com.ar). Deja
|
|
4
|
+
que un asistente de IA (Claude Desktop / Claude Code) opere tu nodo o tu cuenta de
|
|
5
|
+
vendedor con lenguaje natural: altas de clientes, consulta de stock disponible,
|
|
6
|
+
rentabilidad por SKU, KPIs y precios. **Nunca toca dinero** (facturación, cobros,
|
|
7
|
+
cuentas, liquidaciones están bloqueados por diseño).
|
|
8
|
+
|
|
9
|
+
## Uso rápido
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# 1) Autorizá una vez (se abre el navegador):
|
|
13
|
+
npx -y nexusflex-mcp@latest login
|
|
14
|
+
|
|
15
|
+
# 2) En claude_desktop_config.json:
|
|
16
|
+
# "nexusflex": { "command": "npx", "args": ["-y", "nexusflex-mcp@latest"] }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Guía completa en español: [INSTALAR.md](./INSTALAR.md).
|
|
20
|
+
|
|
21
|
+
## Cómo funciona (seguridad)
|
|
22
|
+
|
|
23
|
+
- **Login por device-flow (autorización web):** el MCP pide un código, lo autorizás con un
|
|
24
|
+
click desde la web (ya logueado), y recibe un **token de vida larga, revocable** guardado
|
|
25
|
+
localmente. No se pega email/contraseña.
|
|
26
|
+
- **Herencia de scope:** el token es un puntero opaco a tu usuario; el backend re-lee tu
|
|
27
|
+
rol/permisos/nodo **frescos en cada request**. El MCP no puede hacer nada que vos no
|
|
28
|
+
puedas desde la web. Revocar un permiso (o el token) en la app corta el acceso al instante.
|
|
29
|
+
- **Aislamiento en 3 capas:** gating del backend (por rol + nodo/cliente) · tools por rol ·
|
|
30
|
+
denylist de dinero (en el cliente **y** en el backend).
|
|
31
|
+
|
|
32
|
+
## Comandos
|
|
33
|
+
|
|
34
|
+
| Comando | Qué hace |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `npx nexusflex-mcp` | Arranca el servidor MCP (stdio). Lo usa Claude Desktop. |
|
|
37
|
+
| `npx nexusflex-mcp login` | Autoriza por device-flow y guarda el token. |
|
|
38
|
+
| `npx nexusflex-mcp logout` | Borra el token local. |
|
|
39
|
+
|
|
40
|
+
## Variables de entorno
|
|
41
|
+
|
|
42
|
+
| Var | Default | Descripción |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `NEXUSFLEX_API_URL` | `https://nexusflex.com.ar/api` | Base de la API. |
|
|
45
|
+
| `NEXUSFLEX_MCP_ALLOW_WRITE` | `false` | Habilita altas/ediciones (nunca dinero). |
|
|
46
|
+
| `NEXUSFLEX_MCP_ALLOW_PRECIOS` | `false` | Habilita editar listas de precios. |
|
|
47
|
+
| `NEXUSFLEX_TOKEN` | — | Token ya emitido (salta el device-flow). |
|
|
48
|
+
| `NEXUSFLEX_EMAIL` / `NEXUSFLEX_PASSWORD` | — | Login legado por contraseña. |
|
|
49
|
+
|
|
50
|
+
## Licencia
|
|
51
|
+
|
|
52
|
+
MIT.
|
package/api.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Cliente HTTP fino y autenticado contra la API REST de Nexus Flex.
|
|
3
|
+
//
|
|
4
|
+
// PRINCIPIO DE SEGURIDAD (no romper): el MCP NO habla con la base de datos ni
|
|
5
|
+
// firma tokens propios. Usa un TOKEN REAL (device-flow, o el JWT del login) y
|
|
6
|
+
// TODA llamada pasa por el middleware de permisos del backend (requireAuth/
|
|
7
|
+
// requirePermiso + scope por nodo). El aislamiento entre nodos y por rol se
|
|
8
|
+
// hereda 100% del backend: el MCP no puede hacer nada que ese usuario no pueda.
|
|
9
|
+
//
|
|
10
|
+
// Autenticación (en orden de preferencia):
|
|
11
|
+
// 1) NEXUSFLEX_TOKEN token explícito (MCP o JWT) por env
|
|
12
|
+
// 2) token guardado ~/.nexusflex-mcp/token.json (device-flow previo)
|
|
13
|
+
// 3) NEXUSFLEX_EMAIL/PASSWORD login legado (compatibilidad hacia atrás)
|
|
14
|
+
// 4) device-flow interactivo imprime URL + código, autorizás en el navegador
|
|
15
|
+
// ============================================================================
|
|
16
|
+
import { loadToken, saveToken, clearToken, runDeviceFlow } from "./device-auth.mjs";
|
|
17
|
+
|
|
18
|
+
const API_URL = (process.env.NEXUSFLEX_API_URL ?? "https://nexusflex.com.ar/api").replace(/\/+$/, "");
|
|
19
|
+
const EMAIL = process.env.NEXUSFLEX_EMAIL ?? "";
|
|
20
|
+
const PASSWORD = process.env.NEXUSFLEX_PASSWORD ?? "";
|
|
21
|
+
const NO_AUTH_PROMPT = /^(1|true|yes|si|sí)$/i.test(process.env.NEXUSFLEX_MCP_NO_AUTH_PROMPT ?? "");
|
|
22
|
+
let TOKEN = process.env.NEXUSFLEX_TOKEN ?? "";
|
|
23
|
+
let usandoDeviceFlow = false; // el token vino de un device-flow/guardado (no email/pass)
|
|
24
|
+
|
|
25
|
+
export { API_URL };
|
|
26
|
+
|
|
27
|
+
// Denylist de DEFENSA EN PROFUNDIDAD: aunque en el futuro alguien agregue un tool
|
|
28
|
+
// por error, el cliente NUNCA llama a endpoints que muevan dinero o expongan datos
|
|
29
|
+
// financieros. El backend además lo bloquea server-side para tokens de MCP (mcpMoneyGuard).
|
|
30
|
+
const RUTAS_PROHIBIDAS = [
|
|
31
|
+
/^\/liquidaciones/i, /^\/cuentas/i, /^\/cobros/i, /^\/afip/i, /^\/personal/i,
|
|
32
|
+
/^\/balance/i, /^\/reportes/i, /^\/programacion/i, /^\/recuento/i, /^\/whatsapp/i,
|
|
33
|
+
/^\/logisticas\/[^/]*\/cargo/i, /^\/logisticas\/saldos/i, /^\/logisticas\/facturar/i,
|
|
34
|
+
/^\/logisticas\/costo/i, /\/facturacion/i,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Timeout de red por llamada (ms). Un backend colgado no debe colgar el tool. */
|
|
38
|
+
const FETCH_TIMEOUT_MS = Number(process.env.NEXUSFLEX_MCP_TIMEOUT_MS) || 30000;
|
|
39
|
+
|
|
40
|
+
/** SIEMPRE loguear a stderr: stdout es el canal del transporte MCP (stdio). */
|
|
41
|
+
export function log(...args) {
|
|
42
|
+
console.error("[nexusflex-mcp]", ...args);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Aviso si se van a mandar credenciales por HTTP sin cifrar (login legado).
|
|
46
|
+
if (EMAIL && PASSWORD && !/^https:\/\//i.test(API_URL)) {
|
|
47
|
+
log("ADVERTENCIA: NEXUSFLEX_API_URL no es HTTPS y hay email/contraseña — viajarían sin cifrar.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function guard(path) {
|
|
51
|
+
const clean = path.split("?")[0];
|
|
52
|
+
if (RUTAS_PROHIBIDAS.some((re) => re.test(clean))) {
|
|
53
|
+
throw new Error(`Ruta bloqueada por política del MCP (endpoints de dinero deshabilitados): ${clean}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function login() {
|
|
58
|
+
const r = await fetch(`${API_URL}/auth/login`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
|
|
62
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
63
|
+
});
|
|
64
|
+
if (!r.ok) throw new Error(`Login falló (${r.status}). Revisá email/contraseña del nodo.`);
|
|
65
|
+
const data = await r.json();
|
|
66
|
+
if (!data?.token) throw new Error("Login sin token en la respuesta.");
|
|
67
|
+
TOKEN = data.token;
|
|
68
|
+
usandoDeviceFlow = false;
|
|
69
|
+
log(`Sesión iniciada como ${data.usuario?.email} (rol ${data.usuario?.rol}, nodo ${data.usuario?.logisticaId ?? "global"}).`);
|
|
70
|
+
return TOKEN;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Corre el device-flow, guarda el token y lo deja listo para usar. */
|
|
74
|
+
async function autorizarPorDeviceFlow() {
|
|
75
|
+
const token = await runDeviceFlow(API_URL, { open: true, log });
|
|
76
|
+
saveToken(token, API_URL);
|
|
77
|
+
TOKEN = token;
|
|
78
|
+
usandoDeviceFlow = true;
|
|
79
|
+
return TOKEN;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Resuelve un token válido siguiendo el orden de preferencia. */
|
|
83
|
+
async function ensureToken() {
|
|
84
|
+
if (TOKEN) return TOKEN;
|
|
85
|
+
// 2) token guardado de un device-flow anterior (atado a esta API).
|
|
86
|
+
const guardado = loadToken(API_URL);
|
|
87
|
+
if (guardado) { TOKEN = guardado; usandoDeviceFlow = true; return TOKEN; }
|
|
88
|
+
// 3) login legado por email/contraseña (compatibilidad).
|
|
89
|
+
if (EMAIL && PASSWORD) return login();
|
|
90
|
+
// 4) device-flow interactivo (a menos que se deshabilite explícitamente).
|
|
91
|
+
if (NO_AUTH_PROMPT) {
|
|
92
|
+
throw new Error("No hay token. Ejecutá `npx nexusflex-mcp login` para autorizar, o definí NEXUSFLEX_TOKEN.");
|
|
93
|
+
}
|
|
94
|
+
return autorizarPorDeviceFlow();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Llamada autenticada a la API. Reintenta UNA vez si el token venció/se revocó. */
|
|
98
|
+
export async function api(method, path, body) {
|
|
99
|
+
guard(path);
|
|
100
|
+
let token = await ensureToken();
|
|
101
|
+
const doFetch = (t) =>
|
|
102
|
+
fetch(`${API_URL}${path}`, {
|
|
103
|
+
method,
|
|
104
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
|
|
105
|
+
body: body != null ? JSON.stringify(body) : undefined,
|
|
106
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
let r = await doFetch(token);
|
|
110
|
+
if (r.status === 401) {
|
|
111
|
+
if (EMAIL && PASSWORD && !usandoDeviceFlow) {
|
|
112
|
+
// Token JWT vencido: re-login por credenciales.
|
|
113
|
+
log("401: token vencido, reintentando login…");
|
|
114
|
+
token = await login();
|
|
115
|
+
r = await doFetch(token);
|
|
116
|
+
} else if (usandoDeviceFlow && !NO_AUTH_PROMPT) {
|
|
117
|
+
// Token de MCP inválido/revocado: limpiamos y re-autorizamos por device-flow.
|
|
118
|
+
log("401: el token de MCP no es válido (revocado/vencido). Re-autorizando…");
|
|
119
|
+
clearToken();
|
|
120
|
+
TOKEN = "";
|
|
121
|
+
token = await autorizarPorDeviceFlow();
|
|
122
|
+
r = await doFetch(token);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const text = await r.text();
|
|
127
|
+
let data = null;
|
|
128
|
+
try {
|
|
129
|
+
data = text ? JSON.parse(text) : null;
|
|
130
|
+
} catch {
|
|
131
|
+
data = text;
|
|
132
|
+
}
|
|
133
|
+
log(`${method} ${path.split("?")[0]} -> ${r.status}`);
|
|
134
|
+
return { ok: r.ok, status: r.status, data };
|
|
135
|
+
}
|
package/device-auth.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nexusflex-mcp",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "MCP de Nexus Flex: operá tu nodo/cuenta desde un asistente de IA (altas de clientes, stock, KPIs). Login por autorización web (device-flow). NO toca dinero.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Nexus Flex",
|
|
8
|
+
"homepage": "https://nexusflex.com.ar/mcp",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ezequieldos/nexus-flex.git",
|
|
12
|
+
"directory": "mcp"
|
|
13
|
+
},
|
|
14
|
+
"bugs": { "url": "https://github.com/ezequieldos/nexus-flex/issues" },
|
|
15
|
+
"keywords": ["mcp", "modelcontextprotocol", "nexusflex", "logistica", "claude"],
|
|
16
|
+
"engines": { "node": ">=20" },
|
|
17
|
+
"bin": { "nexusflex-mcp": "server.mjs" },
|
|
18
|
+
"files": ["server.mjs", "api.mjs", "device-auth.mjs", "README.md", "INSTALAR.md"],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node server.mjs",
|
|
21
|
+
"login": "node server.mjs login",
|
|
22
|
+
"logout": "node server.mjs logout"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
26
|
+
"zod": "^3.24.1"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": { "access": "public" }
|
|
29
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
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"}.`);
|