nexusflex-mcp 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALAR.md +97 -97
- package/LICENSE +21 -21
- package/README.md +52 -52
- package/api.mjs +135 -135
- package/device-auth.mjs +132 -132
- package/package.json +29 -29
- package/server.mjs +331 -315
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/package.json
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "nexusflex-mcp",
|
|
3
|
-
"version": "3.
|
|
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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "nexusflex-mcp",
|
|
3
|
+
"version": "3.1.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
|
+
}
|