utn-mcp 0.6.9 → 0.8.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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # UTN-MCP — El puente entre tu agente y los sistemas de la Universidad.
2
- Este es el Model Context Protocol server NO OFICIAL de la UTN. Con él podrás pedirle a tu agente de preferencia (ChatGPT, Claude, etc.) que busque informacion o archivos en el aula virtual.
2
+ Este es el Model Context Protocol server NO OFICIAL de la UTN. Con él podés pedirle a tu agente de preferencia (Claude, ChatGPT, etc.) que busque información y lea los apuntes del aula virtual y, con tu confirmación, que publique en los foros o entregue tareas. Corre en tu computadora o como servidor HTTP con OAuth, para usarlo desde cualquier lado.
3
3
 
4
4
  ## Qué podés pedirle
5
5
  - *"¿Cuándo es el recuperatorio del primer parcial de Álgebra?"*
@@ -19,7 +19,7 @@ El agente recorre el aula como lo harías vos: entra al curso, mira los foros y
19
19
  | `aula_list_courses` | Lista tus cursos. Es el punto de partida. |
20
20
  | `aula_open` | Entra a un curso, un módulo, un foro o una discusión. Cada respuesta trae las direcciones de lo que hay adentro y de un nivel más arriba, así el agente navega sin perderse. |
21
21
  | `aula_read_file` | Lee un archivo del aula, por partes cuando es largo. |
22
- | `aula_get_site_info` | Datos de tu usuario en el aula virtual. |
22
+ | `aula_get_site_info` | Datos de tu usuario y de tu regional en el aula virtual. |
23
23
  | `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
24
24
  | `aula_forum_edit_post` | Edita una publicación tuya. |
25
25
  | `aula_forum_delete_post` | Borra una publicación tuya. |
@@ -48,7 +48,7 @@ Necesitás [Node.js](https://nodejs.org) 20 o superior. No hace falta clonar el
48
48
  ```bash
49
49
  npx -y utn-mcp login
50
50
  ```
51
- Te pide tu usuario institucional y tu contraseña de la UTN (los mismos de "Usuarios Institucionales" en el aula virtual). Se hace una sola vez: después el servidor renueva la sesión solo.
51
+ Te pide tu regional, tu usuario y tu contraseña: los mismos con los que entrás al aula virtual. Si no tenés usuario institucional (por ejemplo, en el curso de ingreso), usá el que te dieron para el aula. Se hace una sola vez: después el servidor renueva la sesión solo.
52
52
 
53
53
  ### 2. Agregarlo a tu cliente
54
54
 
@@ -107,7 +107,7 @@ mcp_servers:
107
107
  }
108
108
  ```
109
109
 
110
- **ChatGPT**: todavía no. ChatGPT sólo se conecta a servidores MCP remotos por HTTPS, y utn-mcp corre en tu computadora. Va a funcionar con la versión hosteada.
110
+ **ChatGPT**: solo se conecta a servidores MCP remotos por HTTPS, así que necesita un servidor en modo HTTP publicado en internet (ver [Hostearlo para otros](#hostearlo-para-otros)). Se agrega como conector desde *Configuración → Apps y conectores*, con el modo desarrollador activado. Los conectores propios están desde el plan Pro, y ahí solo con las herramientas de lectura; para usar también las de escritura (foros, mensajes, entregas) hace falta Business o superior.
111
111
 
112
112
  **Otros clientes (Windsurf, opencode, etc.)**: cualquier cliente MCP que lance servidores por STDIO sirve con el comando `npx -y utn-mcp`.
113
113
 
@@ -119,6 +119,20 @@ utn-mcp login
119
119
  ```
120
120
  y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
121
121
 
122
+ ### Regionales
123
+ La regional se elige al iniciar sesión, de una lista con las regionales de la UTN. Buenos Aires (FRBA), Resistencia (FRRE) y Tucumán (FRT) ya están probadas. El login se adapta solo: si el aula entra por SSO (Keycloak), como FRBA, usa el SSO; si no, el login directo de Moodle.
124
+
125
+ La lista puede traer regionales que nadie probó todavía: su aula tiene habilitado el servicio que usa UTN-MCP, así que casi seguro anda. En un servidor hosteado, la primera persona que entra con una de esas la deja verificada para el resto. Las que no tienen el servicio habilitado aparecen como "todavía sin soporte": se pueden elegir, pero el login avisa que por ahora no se puede usar y no manda la contraseña.
126
+
127
+ Si tu campus no está en la lista, abrí un [issue](https://github.com/nialvape/utn-mcp/issues) con la URL de su aula virtual para sumarlo. Mientras tanto, podés usarlo definiendo en un `.env` en la carpeta desde donde corrés `utn-mcp` (o en el entorno):
128
+ ```bash
129
+ UTN_AULA_BASE_URL=https://tu-campus.utn.edu.ar
130
+ # UTN_SSO_HOST=auth.tu-campus.utn.edu.ar # solo si usa Keycloak
131
+ ```
132
+ Aparece como una opción más al iniciar sesión.
133
+
134
+ `UTN_REGIONAL` (por ejemplo, `UTN_REGIONAL=frt`) elige la que viene marcada por defecto, y es la que se usa si en lugar de `login` definís `UTN_USER` y `UTN_PASS`.
135
+
122
136
  ### Modo HTTP (opcional)
123
137
  Necesita Node 22.13 o más nuevo. Primero, una vez:
124
138
  ```bash
@@ -130,16 +144,45 @@ npx -y utn-mcp --http --port 3000
130
144
  ```
131
145
  Levanta el servidor en `http://127.0.0.1:3000/mcp`, solo accesible desde tu propia máquina. Tenés que dejar la terminal abierta mientras lo uses.
132
146
 
133
- En este modo el servidor no lee archivos de tu disco: para entregar, el agente pide un link con `aula_assign_upload_link` y sube ahí los archivos con `curl` si tiene terminal, o te pasa el link para que los elijas desde el navegador. Si el servidor se publica detrás de otra dirección, definí `UTN_MCP_PUBLIC_URL` para que los links la usen.
147
+ En este modo el servidor no lee archivos de tu disco: para entregar, el agente pide un link con `aula_assign_upload_link` y sube ahí los archivos con `curl` si tiene terminal, o te pasa el link para que los elijas desde el navegador.
148
+
149
+ ### Hostearlo para otros
150
+ En modo HTTP el servidor también es servidor de autorización OAuth: un cliente MCP (Claude, ChatGPT) que se conecta a `https://tu-dominio/mcp` recibe un 401, descubre el OAuth y abre una página donde el alumno elige su regional y entra con su usuario de la UTN. Cada alumno usa el aula de su regional, aunque sean de regionales distintas. Ese login vincula su aula: la contraseña se usa una sola vez para sacar el token de Moodle y **no se guarda**. Cuando la sesión del aula vence, el agente le pasa el link de `/login` para renovarla, sin reconectar el cliente.
151
+
152
+ Quién puede entrar lo decidís vos: con `UTN_MCP_ALLOWED_EMAILS` (una lista), o con `UTN_MCP_ACCESS_URL`, que apunta a un servicio tuyo. El servidor le hace `POST` con `{ "email", "usuario", "regional" }` y el header `Authorization: Bearer <UTN_MCP_ACCESS_SECRET>`, y espera `{ "permitido": true }` o `{ "permitido": false, "motivo": "...", "url": "..." }`. Se consulta al conectar y en cada renovación del token (cada hora), así que una baja se aplica sola.
153
+
154
+ La configuración va en variables de entorno o en un `.env` en la carpeta desde donde lo corrés (copiá [`.env.example`](https://github.com/nialvape/utn-mcp/blob/main/.env.example)):
155
+
156
+ | Variable | Para qué | Por defecto |
157
+ |---|---|---|
158
+ | `HOST`, `PORT` | Dónde escucha. | `127.0.0.1`, `3000` |
159
+ | `UTN_MCP_PUBLIC_URL` | Dirección pública (`https://mcp.tu-dominio.com`). Es el issuer del OAuth, la base de los links de subida y el único host, además de localhost, que se acepta en `Host` y `Origin`. | `http://127.0.0.1:<PORT>` |
160
+ | `UTN_MCP_ACCESS_URL`, `UTN_MCP_ACCESS_SECRET` | Servicio que decide quién puede entrar. | — |
161
+ | `UTN_MCP_ALLOWED_EMAILS` | Emails o dominios (`@frba.utn.edu.ar`) habilitados, separados por coma, si no hay `UTN_MCP_ACCESS_URL`. | Entra cualquiera con usuario de la UTN |
162
+ | `UTN_MCP_REDIRECT_ALLOW` | Hosts (`app.ejemplo.com`) o esquemas (`miapp:`) extra, separados por coma, a los que puede volver el login de OAuth. Loopback, Claude, ChatGPT, VS Code y Cursor pasan siempre. Cualquier otro host HTTPS (un agente propio, por ejemplo) se acepta, pero el login avisa a dónde vuelve y el alumno tiene que marcar que lo conecta bajo su responsabilidad: es la defensa contra alguien que use tu página de login para quedarse con su sesión. Los hosts de esta lista pasan sin aviso. | — |
163
+ | `UTN_MCP_ADMIN_SECRET` | Habilita la API de `/admin` para un panel de cuentas tuyo: `POST /admin/usuarios` (todos los usuarios con sus apps), `/admin/clientes` y `/admin/clientes/revocar` (las apps de un usuario, por email), `/admin/regionales` (las regionales con su soporte) y `/admin/verificar` (entra al aula con usuario y contraseña, como el login de OAuth, y le guarda la sesión). | — |
164
+ | `UTN_MCP_LOGIN_URL` | Página donde el usuario renueva su sesión del aula; el agente se la pasa. | `<UTN_MCP_PUBLIC_URL>/login` |
165
+ | `UTN_MCP_DB`, `UTN_MCP_KEY` | Base de sesiones y su clave de cifrado (32 bytes en base64). | En la carpeta de config |
166
+
167
+ Con Docker, detrás de un reverse proxy con HTTPS en la misma máquina (red host, para que el rate limit del login vea la IP real):
168
+ ```bash
169
+ docker build -t utn-mcp .
170
+ docker run -d --name utn-mcp --network host --restart unless-stopped \
171
+ -e HOST=127.0.0.1 -e PORT=8100 -e UTN_MCP_PUBLIC_URL=https://mcp.tu-dominio.com \
172
+ -e UTN_MCP_KEY=$(openssl rand -base64 32) -v utn-mcp-data:/data utn-mcp
173
+ ```
174
+ Guardá esa `UTN_MCP_KEY`: sin ella no se pueden descifrar las sesiones guardadas.
134
175
 
135
176
  ## Dónde se guardan tus datos
136
- Todo queda en tu computadora:
177
+ Instalado en tu computadora, todo queda ahí:
137
178
  - **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
138
- - **Token del aula virtual y cookies de sesión**: en `%APPDATA%\utn-mcp\session.json` (Windows) o `~/.config/utn-mcp/session.json` (Linux y macOS).
139
- - **En modo HTTP**: la sesión va cifrada en `utn-mcp.db`, en la misma carpeta, con la clave de `store.key` (o la de `UTN_MCP_KEY`). Del token del cliente solo se guarda el hash.
179
+ - **Token del aula virtual**: en `%APPDATA%\utn-mcp\session.json` (Windows) o `~/.config/utn-mcp/session.json` (Linux y macOS).
180
+ - **En modo HTTP**: la contraseña no se guarda; la sesión va cifrada en `utn-mcp.db`, en la misma carpeta, con la clave de `store.key` (o la de `UTN_MCP_KEY`). Del token del cliente solo se guarda el hash.
140
181
 
141
182
  En Linux sin gestor de credenciales (servidores, Docker, WSL) podés definir las variables de entorno `UTN_USER` y `UTN_PASS` en lugar de usar `login`.
142
183
 
184
+ Si usás un servidor hosteado por otra persona, tu sesión del aula la guarda ese servidor (cifrada, y sin tu contraseña): revisá su política de privacidad.
185
+
143
186
  ## Desarrollo
144
187
  ```bash
145
188
  git clone https://github.com/nialvape/utn-mcp.git
@@ -2,6 +2,15 @@ import { callWs, MoodleError } from "./moodle-api.js";
2
2
  export function getSiteInfo(session) {
3
3
  return callWs(session, "core_webservice_get_site_info");
4
4
  }
5
+ /** El usuario dueño del token, con su email institucional (site_info no lo trae). */
6
+ export async function getOwnUser(session) {
7
+ const { userid, username, fullname } = await getSiteInfo(session);
8
+ const [user] = await callWs(session, "core_user_get_users_by_field", {
9
+ field: "id",
10
+ "values[0]": userid,
11
+ });
12
+ return { username, fullname, email: user?.email || null };
13
+ }
5
14
  export async function getUserCourses(session) {
6
15
  const { userid } = await getSiteInfo(session);
7
16
  return callWs(session, "core_enrol_get_users_courses", { userid });
@@ -0,0 +1,33 @@
1
+ import { getOwnUser, getUserCourses } from "./client.js";
2
+ import { fetchToken } from "./sso-login.js";
3
+ import { AULA_PLATFORM } from "./stored-session.js";
4
+ /**
5
+ * Entra al aula de la regional con usuario y contraseña, crea el usuario del store si no existe y le
6
+ * guarda la sesión (el token). La contraseña se usa solo acá: no se guarda en ningún lado.
7
+ * Lo usan `utn-mcp login --http`, el login por OAuth de la versión hosteada y `/admin/verificar`.
8
+ * `regional` tiene que venir con su soporte en este servidor (`availableRegionals` con las verificadas).
9
+ */
10
+ export async function linkAulaAccount(store, regional, credentials) {
11
+ const token = await fetchToken(regional, credentials);
12
+ // El token recién emitido: si falla, no hay con qué renovarlo.
13
+ const fresh = {
14
+ getRegional: async () => regional,
15
+ getToken: async () => token,
16
+ refreshToken: async () => {
17
+ throw new Error("El aula rechazó el token recién emitido.");
18
+ },
19
+ };
20
+ const own = await getOwnUser(fresh);
21
+ // La primera vez que alguien entra a una regional sin verificar se prueban también los cursos: si
22
+ // anda, queda verificada para todos los de este servidor.
23
+ if (regional.support === "candidate") {
24
+ await getUserCourses(fresh);
25
+ await store.markRegionalVerified(regional.id);
26
+ }
27
+ // El de Moodle, en minúscula, igual que en `users`: es con lo que se compara al renovar.
28
+ const username = own.username.toLowerCase();
29
+ const userId = await store.findOrCreateUser(regional.id, username, own.email ?? undefined);
30
+ const data = { username, token };
31
+ await store.saveSession(userId, AULA_PLATFORM, JSON.stringify(data));
32
+ return { userId, regional, username, fullname: own.fullname, email: own.email };
33
+ }
@@ -1,35 +1,43 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
- import { CookieJar } from "tough-cookie";
4
3
  import { sessionFile } from "../../shared/paths.js";
5
4
  import { logger } from "../../shared/logger.js";
6
5
  import { loadCredentials } from "./credentials.js";
7
- import { fetchToken } from "./sso-login.js";
6
+ import { defaultRegional, findRegional } from "./regionals.js";
7
+ import { fetchToken, LoginRequiredError } from "./sso-login.js";
8
8
  /**
9
9
  * Sesión del modo local: un solo usuario, guardada en disco.
10
- * Credenciales en el keychain (o UTN_USER/UTN_PASS); token y cookies del SSO en session.json.
10
+ * Credenciales en el keychain (o UTN_USER/UTN_PASS); el token y la regional en session.json.
11
11
  */
12
12
  export class LocalSession {
13
13
  refreshing = null;
14
+ /**
15
+ * La elegida en `utn-mcp login`: la de session.json o, si se borró, la guardada con las credenciales.
16
+ * Sin login (UTN_USER/UTN_PASS) o de antes de elegirla, la de UTN_REGIONAL.
17
+ */
18
+ async getRegional() {
19
+ const regional = (await this.read()).regional ?? (await loadCredentials())?.regional;
20
+ return regional ? findRegional(regional) : defaultRegional();
21
+ }
14
22
  async getToken() {
15
23
  return (await this.read()).token ?? this.refreshToken();
16
24
  }
17
25
  /** Una sola renovación en curso aunque varias tools fallen a la vez. */
18
26
  refreshToken() {
19
- this.refreshing ??= loadCredentials()
20
- .then((creds) => this.login(creds))
27
+ this.refreshing ??= Promise.all([this.getRegional(), loadCredentials()])
28
+ .then(([regional, creds]) => this.login(regional, creds))
21
29
  .finally(() => {
22
30
  this.refreshing = null;
23
31
  });
24
32
  return this.refreshing;
25
33
  }
26
- /** Obtiene un token nuevo y lo guarda junto con las cookies del SSO. */
27
- async login(credentials) {
28
- logger.info("Obteniendo token del aula virtual...");
29
- const stored = await this.read();
30
- const jar = stored.cookies ? await CookieJar.deserialize(stored.cookies) : new CookieJar();
31
- const token = await fetchToken(jar, credentials);
32
- await this.write({ token, obtainedAt: new Date().toISOString(), cookies: await jar.serialize() });
34
+ /** Obtiene un token nuevo y lo guarda. */
35
+ async login(regional, credentials) {
36
+ if (!credentials)
37
+ throw new LoginRequiredError();
38
+ logger.info(`Obteniendo token del aula virtual de ${regional.name}...`);
39
+ const token = await fetchToken(regional, credentials);
40
+ await this.write({ token, obtainedAt: new Date().toISOString(), regional: regional.id });
33
41
  return token;
34
42
  }
35
43
  async read() {
@@ -37,7 +45,7 @@ export class LocalSession {
37
45
  return JSON.parse(await readFile(sessionFile(), "utf8"));
38
46
  }
39
47
  catch {
40
- return { token: null, obtainedAt: null, cookies: null };
48
+ return { token: null, obtainedAt: null };
41
49
  }
42
50
  }
43
51
  async write(data) {
@@ -1,7 +1,6 @@
1
1
  import { postForm } from "../../shared/http.js";
2
- import { AULA_BASE_URL } from "./constants.js";
3
- const WS_URL = `${AULA_BASE_URL}/webservice/rest/server.php`;
4
- const UPLOAD_URL = `${AULA_BASE_URL}/webservice/upload.php`;
2
+ const WS_PATH = "/webservice/rest/server.php";
3
+ const UPLOAD_PATH = "/webservice/upload.php";
5
4
  const TOKEN_ERRORS = new Set(["invalidtoken", "expiredtoken"]);
6
5
  export class MoodleError extends Error {
7
6
  errorcode;
@@ -14,9 +13,10 @@ export class MoodleError extends Error {
14
13
  const isException = (data) => typeof data === "object" && data !== null && "exception" in data;
15
14
  /** Llama a una función del web service de Moodle; renueva el token una vez si venció. */
16
15
  export async function callWs(session, wsfunction, params = {}) {
16
+ const { baseUrl } = await session.getRegional();
17
17
  let token = await session.getToken();
18
18
  for (let attempt = 0;; attempt++) {
19
- const data = await postForm(WS_URL, {
19
+ const data = await postForm(baseUrl + WS_PATH, {
20
20
  ...params,
21
21
  wstoken: token,
22
22
  wsfunction,
@@ -66,6 +66,7 @@ export async function fetchFile(session, url, maxBytes) {
66
66
  * que es lo que piden las funciones que guardan archivos. Todos quedan en el mismo borrador.
67
67
  */
68
68
  export async function uploadDraftFiles(session, files) {
69
+ const { baseUrl } = await session.getRegional();
69
70
  let token = await session.getToken();
70
71
  for (let attempt = 0;; attempt++) {
71
72
  const form = new FormData();
@@ -75,7 +76,7 @@ export async function uploadDraftFiles(session, files) {
75
76
  files.forEach((f, i) => form.set(`file_${i + 1}`, new Blob([new Uint8Array(f.bytes)]), f.name));
76
77
  let res;
77
78
  try {
78
- res = await fetch(UPLOAD_URL, { method: "POST", body: form, signal: AbortSignal.timeout(120_000) });
79
+ res = await fetch(baseUrl + UPLOAD_PATH, { method: "POST", body: form, signal: AbortSignal.timeout(120_000) });
79
80
  }
80
81
  catch (e) {
81
82
  throw new MoodleError(`No se pudo subir el archivo: ${e.message}`);
@@ -0,0 +1,103 @@
1
+ /**
2
+ * El soporte de las que no son `verified` sale de `tool_mobile_get_public_config`, que Moodle contesta
3
+ * sin login (octubre de 2026). Falta Villa María (FRVM): no encontramos su aula.
4
+ */
5
+ const REGIONALS = [
6
+ { id: "fra", name: "Avellaneda (FRA)", baseUrl: "https://www.utnfravirtual.org.ar", support: "candidate" },
7
+ { id: "frbb", name: "Bahía Blanca (FRBB)", baseUrl: "https://aulavirtual.frbb.utn.edu.ar", support: "candidate" },
8
+ {
9
+ id: "frba",
10
+ name: "Buenos Aires (FRBA)",
11
+ baseUrl: "https://aulasvirtuales.frba.utn.edu.ar",
12
+ ssoHost: "auth.frba.utn.edu.ar",
13
+ support: "verified",
14
+ },
15
+ { id: "frch", name: "Chubut (FRCh)", baseUrl: "https://frch.cvg.utn.edu.ar", support: "candidate" },
16
+ { id: "frcu", name: "Concepción del Uruguay (FRCU)", baseUrl: "https://virtual.frcu.utn.edu.ar", support: "candidate" },
17
+ { id: "frcon", name: "Concordia (FRCon)", baseUrl: "https://frcon.cvg.utn.edu.ar", support: "candidate" },
18
+ { id: "frc", name: "Córdoba (FRC)", baseUrl: "https://uv.frc.utn.edu.ar", support: "verified" },
19
+ { id: "frd", name: "Delta (FRD)", baseUrl: "https://frd.cvg.utn.edu.ar", support: "candidate" },
20
+ { id: "frgp", name: "General Pacheco (FRGP)", baseUrl: "https://frgp.cvg.utn.edu.ar", support: "candidate" },
21
+ // Office 365.
22
+ { id: "frh", name: "Haedo (FRH)", baseUrl: "https://frh.cvg.utn.edu.ar", support: "unsupported" },
23
+ { id: "frlp", name: "La Plata (FRLP)", baseUrl: "https://frlp.cvg.utn.edu.ar", support: "candidate" },
24
+ { id: "frlr", name: "La Rioja (FRLR)", baseUrl: "https://frlr.cvg.utn.edu.ar", support: "candidate" },
25
+ // Tiene también frmdp.cvg.utn.edu.ar, pero ese no tiene el servicio habilitado y entra con Office 365.
26
+ { id: "frmdp", name: "Mar del Plata (FRMDP)", baseUrl: "https://campus.mdp.utn.edu.ar", support: "candidate" },
27
+ // Sin el servicio habilitado.
28
+ { id: "frm", name: "Mendoza (FRM)", baseUrl: "https://campusvirtual.frm.utn.edu.ar", support: "unsupported" },
29
+ { id: "frn", name: "Neuquén (FRN)", baseUrl: "https://frn.cvg.utn.edu.ar", support: "candidate" },
30
+ { id: "frp", name: "Paraná (FRP)", baseUrl: "https://frp.cvg.utn.edu.ar", support: "candidate" },
31
+ { id: "frra", name: "Rafaela (FRRa)", baseUrl: "https://frra.cvg.utn.edu.ar", support: "candidate" },
32
+ { id: "frrq", name: "Reconquista (FRRq)", baseUrl: "https://frrq.cvg.utn.edu.ar", support: "candidate" },
33
+ { id: "frre", name: "Resistencia (FRRE)", baseUrl: "https://frre.cvg.utn.edu.ar", support: "verified" },
34
+ { id: "frro", name: "Rosario (FRRo)", baseUrl: "https://frro.cvg.utn.edu.ar", support: "candidate" },
35
+ { id: "frsfco", name: "San Francisco (FRSFco)", baseUrl: "https://frsfco.cvg.utn.edu.ar", support: "candidate" },
36
+ // Office 365.
37
+ { id: "frsn", name: "San Nicolás (FRSN)", baseUrl: "https://frsn.cvg.utn.edu.ar", support: "unsupported" },
38
+ { id: "frsr", name: "San Rafael (FRSR)", baseUrl: "https://campus.frsr.utn.edu.ar/moodle", support: "candidate" },
39
+ { id: "frsc", name: "Santa Cruz (FRSC)", baseUrl: "https://frsc.cvg.utn.edu.ar", support: "candidate" },
40
+ { id: "frsf", name: "Santa Fe (FRSF)", baseUrl: "https://campusvirtual.frsf.utn.edu.ar", support: "candidate" },
41
+ // Sin el servicio habilitado.
42
+ { id: "frtdf", name: "Tierra del Fuego (FRTDF)", baseUrl: "https://cvg.frtdf.utn.edu.ar", support: "unsupported" },
43
+ { id: "frtl", name: "Trenque Lauquen (FRTL)", baseUrl: "https://frtl.cvg.utn.edu.ar", support: "candidate" },
44
+ { id: "frt", name: "Tucumán (FRT)", baseUrl: "https://frt.cvg.utn.edu.ar", support: "verified" },
45
+ // Sin el servicio habilitado.
46
+ { id: "frvt", name: "Venado Tuerto (FRVT)", baseUrl: "https://frvt.cvg.utn.edu.ar", support: "unsupported" },
47
+ ];
48
+ const DEFAULT_REGIONAL = "frba";
49
+ /** `UTN_REGIONAL` no está en el registro: sin cortar acá, las credenciales irían al SSO de FRBA. */
50
+ export class UnknownRegionalError extends Error {
51
+ name = "UnknownRegionalError";
52
+ constructor(regional) {
53
+ super(`"${regional}" no es una regional conocida. Las válidas son: ${availableRegionals().map((r) => r.id).join(", ")}. ` +
54
+ "Si tu campus no está en la lista, borrá UTN_REGIONAL y definí UTN_AULA_BASE_URL con la URL del aula " +
55
+ "(y UTN_SSO_HOST si usa Keycloak).");
56
+ }
57
+ }
58
+ /** Su aula no tiene el servicio habilitado o entra por un SSO que no sabemos usar: ni se intenta entrar. */
59
+ export class UnsupportedRegionalError extends Error {
60
+ regional;
61
+ name = "UnsupportedRegionalError";
62
+ constructor(regional) {
63
+ super(`Todavía no hay soporte para ${regional.name}: su aula no tiene habilitado el servicio que usa ` +
64
+ "UTN-MCP o entra por un login que todavía no sabemos usar.");
65
+ this.regional = regional;
66
+ }
67
+ }
68
+ /** El aula de UTN_AULA_BASE_URL, para un campus que no está en el registro. */
69
+ function customRegional() {
70
+ const raw = process.env.UTN_AULA_BASE_URL?.trim();
71
+ if (!raw)
72
+ return undefined;
73
+ const baseUrl = raw.replace(/\/+$/, "");
74
+ const host = new URL(baseUrl).host;
75
+ return { id: host, name: host, baseUrl, ssoHost: process.env.UTN_SSO_HOST?.trim() || undefined, support: "verified" };
76
+ }
77
+ /**
78
+ * Las que se pueden elegir al iniciar sesión: el catálogo y, si está definida, la de UTN_AULA_BASE_URL.
79
+ * `verifiedIds` son las `candidate` que ya se verificaron en el servidor (las guarda el store).
80
+ */
81
+ export function availableRegionals(verifiedIds = []) {
82
+ const verified = new Set(verifiedIds);
83
+ const custom = customRegional();
84
+ return [...REGIONALS, ...(custom ? [custom] : [])].map((r) => r.support === "candidate" && verified.has(r.id) ? { ...r, support: "verified" } : r);
85
+ }
86
+ /** La regional con ese id; si no está entre las disponibles, `UnknownRegionalError`. */
87
+ export function findRegional(id) {
88
+ const wanted = id.trim().toLowerCase();
89
+ const found = availableRegionals().find((r) => r.id === wanted);
90
+ if (!found)
91
+ throw new UnknownRegionalError(id);
92
+ return found;
93
+ }
94
+ /**
95
+ * La que viene elegida si nadie dice otra: la de UTN_REGIONAL; si no, la de UTN_AULA_BASE_URL; si no,
96
+ * FRBA. Es la de quien usa UTN_USER/UTN_PASS sin `utn-mcp login`, y la opción por defecto al elegir.
97
+ */
98
+ export function defaultRegional() {
99
+ const fromEnv = process.env.UTN_REGIONAL?.trim();
100
+ if (fromEnv)
101
+ return findRegional(fromEnv);
102
+ return customRegional() ?? findRegional(DEFAULT_REGIONAL);
103
+ }
@@ -1,5 +1,7 @@
1
1
  import { randomInt } from "node:crypto";
2
- import { AULA_BASE_URL, SSO_HOST } from "./constants.js";
2
+ import { CookieJar } from "tough-cookie";
3
+ import { UnsupportedRegionalError } from "./regionals.js";
4
+ import { MoodleError } from "./moodle-api.js";
3
5
  export class LoginRequiredError extends Error {
4
6
  constructor(message = "Hace falta iniciar sesión: corré `utn-mcp login`.") {
5
7
  super(message);
@@ -7,56 +9,135 @@ export class LoginRequiredError extends Error {
7
9
  }
8
10
  }
9
11
  export class InvalidCredentialsError extends Error {
10
- constructor() {
11
- super("El SSO rechazó usuario o contraseña. Corré `utn-mcp login` para actualizarlos.");
12
+ constructor(reason) {
13
+ const base = "El aula rechazó usuario o contraseña. Corré `utn-mcp login` para actualizarlos.";
14
+ super(reason ? `${base} Motivo del aula: ${reason}` : base);
12
15
  this.name = "InvalidCredentialsError";
13
16
  }
14
17
  }
15
18
  const MAX_REDIRECTS = 20;
16
19
  const TIMEOUT_MS = 30_000;
17
20
  /**
18
- * Obtiene un token del web service siguiendo el flujo de launch.php por HTTP:
19
- * launch.php → login de Moodle → SSO (Keycloak) → Moodle → launch.php → moodlemobile://token=BASE64.
20
- * Las cookies del SSO quedan en `jar`: mientras sigan vivas no hace falta la contraseña.
21
+ * Obtiene un token del web service. Dos estrategias según la regional:
22
+ * - Regionales con SSO (Keycloak, ej. FRBA): flujo de launch.php, que usa la sesión del SSO. Si el
23
+ * SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en el SSO.
24
+ * - Regionales con login directo de Moodle (ej. FRT): primero `login/token.php` (no depende del
25
+ * plugin de mobile launch, que puede estar deshabilitado); si falla, flujo de launch.php.
26
+ * Las cookies se usan solo durante el login y se descartan: las sesiones de Moodle y del SSO duran
27
+ * horas y el token, semanas, así que no sirven para renovarlo.
21
28
  */
22
- export async function fetchToken(jar, credentials) {
23
- const launchUrl = `${AULA_BASE_URL}/admin/tool/mobile/launch.php` +
29
+ export async function fetchToken(regional, credentials) {
30
+ if (regional.support === "unsupported")
31
+ throw new UnsupportedRegionalError(regional);
32
+ const { baseUrl, ssoHost } = regional;
33
+ const jar = new CookieJar();
34
+ // Regionales con login directo: intentamos token.php primero (no depende de launch.php).
35
+ if (!ssoHost) {
36
+ const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
37
+ if (viaTokenPhp)
38
+ return viaTokenPhp;
39
+ }
40
+ const launchUrl = `${baseUrl}/admin/tool/mobile/launch.php` +
24
41
  `?service=moodle_mobile_app&passport=${randomInt(1e9)}&urlscheme=moodlemobile`;
25
- // 1. Con sesión de Moodle viva, launch.php redirige directo al token.
26
42
  let landing = await follow(jar, launchUrl);
27
43
  if ("token" in landing)
28
44
  return landing.token;
29
- // 2. Login de Moodle: el botón "Usuarios Institucionales" lleva al SSO.
30
45
  const oauthUrl = landing.html.match(/href="([^"]*\/auth\/oauth2\/login\.php[^"]*)"/)?.[1];
31
- if (!oauthUrl)
32
- throw unexpected(landing.url);
33
- landing = await follow(jar, decodeHtml(oauthUrl));
34
- if ("token" in landing)
35
- return landing.token;
36
- // 3. Si el SSO no nos recuerda, muestra su formulario: lo completamos.
37
- if (new URL(landing.url).host === SSO_HOST) {
38
- if (!credentials)
39
- throw new LoginRequiredError();
40
- landing = await submitSsoForm(jar, landing.html, credentials);
46
+ if (oauthUrl) {
47
+ landing = await follow(jar, decodeHtml(oauthUrl));
48
+ if ("token" in landing)
49
+ return landing.token;
50
+ if (ssoHost && new URL(landing.url).host === ssoHost) {
51
+ landing = await submitSsoForm(jar, landing.html, credentials);
52
+ if ("token" in landing)
53
+ return landing.token;
54
+ if (ssoHost && new URL(landing.url).host === ssoHost) {
55
+ // Aspirantes del ingreso y algunas tecnicaturas no tienen cuenta en el SSO: entran con el login de Moodle.
56
+ const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
57
+ if (viaTokenPhp)
58
+ return viaTokenPhp;
59
+ throw new InvalidCredentialsError(extractLoginError(landing.html));
60
+ }
61
+ }
62
+ }
63
+ else {
64
+ const loginPageUrl = `${baseUrl}/login/index.php`;
65
+ const loginPage = await follow(jar, loginPageUrl);
66
+ if ("token" in loginPage)
67
+ return loginPage.token;
68
+ landing = await submitMoodleLoginForm(jar, loginPage.url, loginPage.html, credentials);
41
69
  if ("token" in landing)
42
70
  return landing.token;
43
- if (new URL(landing.url).host === SSO_HOST)
44
- throw new InvalidCredentialsError();
71
+ const stillOnLogin = landing.url.includes("/login") ||
72
+ /alert-danger/i.test(landing.html) ||
73
+ /loginerrors/i.test(landing.html);
74
+ if (stillOnLogin) {
75
+ const reason = extractLoginError(landing.html);
76
+ throw new InvalidCredentialsError(reason);
77
+ }
45
78
  }
46
- // 4. Ya con sesión de Moodle (el SSO nos deja en el dashboard), pedimos el token de nuevo.
47
79
  landing = await follow(jar, launchUrl);
48
80
  if ("token" in landing)
49
81
  return landing.token;
82
+ if (!ssoHost) {
83
+ const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
84
+ if (viaTokenPhp)
85
+ return viaTokenPhp;
86
+ }
50
87
  throw unexpected(landing.url);
51
88
  }
89
+ /**
90
+ * Pide el token a /login/token.php (Moodle estándar). Devuelve null si el aula no lo expone
91
+ * (no contesta JSON), para seguir con launch.php; si contesta con un error, lo propaga.
92
+ */
93
+ async function tryTokenPhp(baseUrl, credentials) {
94
+ const res = await fetch(`${baseUrl}/login/token.php`, {
95
+ method: "POST",
96
+ headers: { "content-type": "application/x-www-form-urlencoded" },
97
+ body: new URLSearchParams({
98
+ username: credentials.username,
99
+ password: credentials.password,
100
+ service: "moodle_mobile_app",
101
+ }),
102
+ signal: AbortSignal.timeout(TIMEOUT_MS),
103
+ });
104
+ let data;
105
+ try {
106
+ data = await res.json();
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ if (data.token)
112
+ return data.token;
113
+ if (data.errorcode === "invalidlogin" || /invalid login/i.test(data.error ?? "")) {
114
+ throw new InvalidCredentialsError(data.error);
115
+ }
116
+ // El mismo servicio lo usa launch.php: si token.php lo rechaza, seguir no sirve.
117
+ throw new MoodleError(`El aula no dio el token (${data.errorcode ?? res.status}): ${data.error ?? "respuesta sin token"}. ` +
118
+ "Si dice que el servicio web no está disponible, lo tiene que habilitar el admin del aula.", data.errorcode);
119
+ }
52
120
  async function submitSsoForm(jar, html, { username, password }) {
53
- // La página de Keycloak trae su config como JSON (kcContext); de ahí sale la URL del form.
54
121
  const action = html.match(/"loginAction"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
55
122
  if (!action)
56
123
  throw new Error("No se encontró el formulario del SSO; puede que haya cambiado.");
57
124
  const body = new URLSearchParams({ username, password, rememberMe: "on", credentialId: "" });
58
125
  return follow(jar, JSON.parse(action), { method: "POST", body });
59
126
  }
127
+ /** Login directo de Moodle (sin SSO): manda usuario y contraseña al formulario de /login/index.php. */
128
+ async function submitMoodleLoginForm(jar, pageUrl, html, { username, password }) {
129
+ const tokenMatch = html.match(/name="logintoken"\s+value="([^"]*)"/) ||
130
+ html.match(/value="([^"]*)"\s+name="logintoken"/);
131
+ const logintoken = tokenMatch?.[1] ?? "";
132
+ const actionMatch = html.match(/<form[^>]*action="([^"]*)"[^>]*>/i);
133
+ let action = actionMatch?.[1] ? decodeHtml(actionMatch[1]) : "";
134
+ if (!action)
135
+ action = pageUrl;
136
+ if (!action.startsWith("http"))
137
+ action = new URL(action, pageUrl).toString();
138
+ const body = new URLSearchParams({ username, password, logintoken, anchor: "" });
139
+ return follow(jar, action, { method: "POST", body });
140
+ }
60
141
  /** Hace la request y sigue redirecciones a mano, guardando cookies en cada salto. */
61
142
  async function follow(jar, url, init) {
62
143
  for (let i = 0; i < MAX_REDIRECTS; i++) {
@@ -92,6 +173,24 @@ export function decodeLaunchToken(url) {
92
173
  return parts[1];
93
174
  }
94
175
  const decodeHtml = (s) => s.replaceAll("&amp;", "&");
176
+ /** Extrae el mensaje de error que muestra Moodle/Keycloak en la página de login. */
177
+ function extractLoginError(html) {
178
+ const patterns = [
179
+ /alert-danger[^>]*>([\s\S]*?)<\/div>/i,
180
+ /alert-error[^>]*>([\s\S]*?)<\/div>/i,
181
+ /class="[^"]*error[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
182
+ /<div[^>]*id="loginerrormessage"[^>]*>([\s\S]*?)<\/div>/i,
183
+ ];
184
+ for (const re of patterns) {
185
+ const m = html.match(re);
186
+ if (m?.[1]) {
187
+ const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
188
+ if (text)
189
+ return text;
190
+ }
191
+ }
192
+ return undefined;
193
+ }
95
194
  const unexpected = (url) => {
96
195
  const { host, pathname } = new URL(url);
97
196
  return new Error(`Flujo de login inesperado: terminó en ${host}${pathname}`);