utn-mcp 0.8.3 → 0.9.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 +26 -1
- package/build/aula-virtual/client/client.d.ts +97 -0
- package/build/aula-virtual/client/credentials.d.ts +10 -0
- package/build/aula-virtual/client/link.d.ts +21 -0
- package/build/aula-virtual/client/local-session.d.ts +23 -0
- package/build/aula-virtual/client/moodle-api.d.ts +25 -0
- package/build/aula-virtual/client/regionals.d.ts +47 -0
- package/build/aula-virtual/client/session.d.ts +12 -0
- package/build/aula-virtual/client/sso-login.d.ts +60 -0
- package/build/aula-virtual/client/stored-session.d.ts +32 -0
- package/build/aula-virtual/client/types.d.ts +173 -0
- package/build/aula-virtual/format/actions.d.ts +57 -0
- package/build/aula-virtual/format/activities.d.ts +86 -0
- package/build/aula-virtual/format/address.d.ts +36 -0
- package/build/aula-virtual/format/fields.d.ts +11 -0
- package/build/aula-virtual/format/files.d.ts +32 -0
- package/build/aula-virtual/format/navigate.d.ts +213 -0
- package/build/aula-virtual/format/study-pack.d.ts +33 -0
- package/build/aula-virtual/format/submission.d.ts +40 -0
- package/build/aula-virtual/login.d.ts +6 -0
- package/build/aula-virtual/tools.d.ts +4 -0
- package/build/autogestion/client/catalog.d.ts +20 -0
- package/build/autogestion/client/catalog.js +22 -0
- package/build/autogestion/client/connector.d.ts +61 -0
- package/build/autogestion/client/connector.js +31 -0
- package/build/autogestion/client/credentials.d.ts +5 -0
- package/build/autogestion/client/credentials.js +40 -0
- package/build/autogestion/client/install.d.ts +14 -0
- package/build/autogestion/client/install.js +72 -0
- package/build/autogestion/client/installed-systems.d.ts +7 -0
- package/build/autogestion/client/installed-systems.js +58 -0
- package/build/autogestion/client/local-session.d.ts +17 -0
- package/build/autogestion/client/local-session.js +37 -0
- package/build/autogestion/client/session.d.ts +14 -0
- package/build/autogestion/client/session.js +1 -0
- package/build/autogestion/client/types.d.ts +49 -0
- package/build/autogestion/client/types.js +1 -0
- package/build/autogestion/format/correlatividades.d.ts +4 -0
- package/build/autogestion/format/correlatividades.js +4 -0
- package/build/autogestion/format/estado-academico.d.ts +4 -0
- package/build/autogestion/format/estado-academico.js +4 -0
- package/build/autogestion/format/examenes.d.ts +3 -0
- package/build/autogestion/format/examenes.js +8 -0
- package/build/autogestion/format/notas-parciales.d.ts +3 -0
- package/build/autogestion/format/notas-parciales.js +8 -0
- package/build/autogestion/login.d.ts +11 -0
- package/build/autogestion/login.js +59 -0
- package/build/autogestion/tools.d.ts +3 -0
- package/build/autogestion/tools.js +73 -0
- package/build/http.d.ts +22 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +11 -1
- package/build/oauth/access.d.ts +18 -0
- package/build/oauth/admin.d.ts +8 -0
- package/build/oauth/expiring.d.ts +25 -0
- package/build/oauth/pending-login.d.ts +20 -0
- package/build/oauth/redirects.d.ts +20 -0
- package/build/oauth/server.d.ts +22 -0
- package/build/server.d.ts +9 -0
- package/build/server.js +21 -2
- package/build/shared/documents/index.d.ts +33 -0
- package/build/shared/documents/merge.d.ts +32 -0
- package/build/shared/documents/office.d.ts +9 -0
- package/build/shared/documents/pdf.d.ts +2 -0
- package/build/shared/documents/png.d.ts +2 -0
- package/build/shared/documents/zip.d.ts +18 -0
- package/build/shared/html.d.ts +6 -0
- package/build/shared/html.js +7 -1
- package/build/shared/http.d.ts +6 -0
- package/build/shared/input.d.ts +7 -0
- package/build/shared/load-env.d.ts +1 -0
- package/build/shared/logger.d.ts +5 -0
- package/build/shared/pages.d.ts +6 -0
- package/build/shared/paths.d.ts +10 -0
- package/build/shared/store/session-store.d.ts +77 -0
- package/build/shared/store/sqlite-store.d.ts +36 -0
- package/build/shared/uploads.d.ts +62 -0
- package/package.json +8 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
import { HttpError } from "../../shared/http.js";
|
|
6
|
+
import { configDir } from "../../shared/paths.js";
|
|
7
|
+
/** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
|
|
8
|
+
export const connectorsDir = () => join(configDir(), "connectors");
|
|
9
|
+
export const connectorDir = (entry) => join(connectorsDir(), entry.package);
|
|
10
|
+
/** El package.json del conector instalado, o null si no está. */
|
|
11
|
+
export async function installedManifest(entry) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(await readFile(join(connectorDir(entry), "package.json"), "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Bloques del formato tar: encabezado y datos van de a 512 bytes. */
|
|
20
|
+
const TAR_BLOCK = 512;
|
|
21
|
+
/**
|
|
22
|
+
* Baja el `.tgz` del catálogo, comprueba su hash y lo extrae en su carpeta. Sin `npm install`: los
|
|
23
|
+
* conectores no tienen dependencias, así que alcanza con desempaquetarlos, y no hace falta npm en la máquina.
|
|
24
|
+
*/
|
|
25
|
+
export async function installConnector(entry) {
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetch(entry.tarball, { signal: AbortSignal.timeout(60_000) });
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
throw new HttpError(`No se pudo bajar el conector de ${entry.name}: ${e.message}`);
|
|
32
|
+
}
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
throw new HttpError(`HTTP ${res.status} al bajar el conector de ${entry.name} (${entry.tarball}).`, res.status);
|
|
35
|
+
const tgz = Buffer.from(await res.arrayBuffer());
|
|
36
|
+
const integrity = `sha512-${createHash("sha512").update(tgz).digest("base64")}`;
|
|
37
|
+
if (integrity !== entry.integrity) {
|
|
38
|
+
throw new Error(`El conector de ${entry.name} que se bajó no es el que espera esta versión de utn-mcp: no se instala.`);
|
|
39
|
+
}
|
|
40
|
+
const target = connectorDir(entry);
|
|
41
|
+
const staging = `${target}.tmp-${process.pid}`;
|
|
42
|
+
await rm(staging, { recursive: true, force: true });
|
|
43
|
+
await extractPackage(tgz, staging);
|
|
44
|
+
await rm(target, { recursive: true, force: true });
|
|
45
|
+
await rename(staging, target);
|
|
46
|
+
}
|
|
47
|
+
/** Extrae los archivos de `package/` de un tarball de `npm pack`; cualquier ruta que salga de ahí se rechaza. */
|
|
48
|
+
async function extractPackage(tgz, dest) {
|
|
49
|
+
const tar = gunzipSync(tgz);
|
|
50
|
+
const text = (start, length) => tar.subarray(start, start + length).toString("utf8").replace(/\0.*$/s, "");
|
|
51
|
+
for (let offset = 0; offset + TAR_BLOCK <= tar.length;) {
|
|
52
|
+
if (tar.subarray(offset, offset + TAR_BLOCK).every((b) => b === 0))
|
|
53
|
+
break;
|
|
54
|
+
const name = text(offset, 100);
|
|
55
|
+
const prefix = text(offset + 345, 155);
|
|
56
|
+
const size = parseInt(text(offset + 124, 12).trim() || "0", 8);
|
|
57
|
+
// "0" o NUL es un archivo común; los demás (carpetas, encabezados pax) no traen nada que escribir.
|
|
58
|
+
const type = String.fromCharCode(tar[offset + 156]);
|
|
59
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
60
|
+
const data = offset + TAR_BLOCK;
|
|
61
|
+
if (type === "0" || type === "\0") {
|
|
62
|
+
const relative = path.replace(/^package\//, "");
|
|
63
|
+
if (!path.startsWith("package/") || isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) {
|
|
64
|
+
throw new Error(`El conector trae una ruta inválida (${path}): no se instala.`);
|
|
65
|
+
}
|
|
66
|
+
const file = join(dest, relative);
|
|
67
|
+
await mkdir(dirname(file), { recursive: true });
|
|
68
|
+
await writeFile(file, tar.subarray(data, data + size));
|
|
69
|
+
}
|
|
70
|
+
offset = data + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AutogestionSystem } from "./connector.js";
|
|
2
|
+
/**
|
|
3
|
+
* Los conectores disponibles: los del catálogo que `utn-mcp login` instaló en la carpeta de datos y,
|
|
4
|
+
* a mano, los paquetes que nombre `UTN_MCP_CONNECTORS`, resueltos desde la carpeta en la que arranca
|
|
5
|
+
* el proceso. Esa variable es para probar un conector propio: cargarlo ejecuta su código.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadInstalledSystems(): Promise<AutogestionSystem[]>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { logger } from "../../shared/logger.js";
|
|
5
|
+
import { CATALOG } from "./catalog.js";
|
|
6
|
+
import { connectorDir, installedManifest } from "./install.js";
|
|
7
|
+
/**
|
|
8
|
+
* Los conectores disponibles: los del catálogo que `utn-mcp login` instaló en la carpeta de datos y,
|
|
9
|
+
* a mano, los paquetes que nombre `UTN_MCP_CONNECTORS`, resueltos desde la carpeta en la que arranca
|
|
10
|
+
* el proceso. Esa variable es para probar un conector propio: cargarlo ejecuta su código.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadInstalledSystems() {
|
|
13
|
+
const systems = [];
|
|
14
|
+
for (const entry of CATALOG) {
|
|
15
|
+
const system = await loadFromCatalog(entry);
|
|
16
|
+
if (system)
|
|
17
|
+
systems.push(system);
|
|
18
|
+
}
|
|
19
|
+
const manual = process.env.UTN_MCP_CONNECTORS?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
|
|
20
|
+
const require = createRequire(join(process.cwd(), "package.json"));
|
|
21
|
+
for (const name of new Set(manual)) {
|
|
22
|
+
const system = validSystem(await import(pathToFileURL(require.resolve(name)).href), name);
|
|
23
|
+
if (systems.some((existing) => existing.id === system.id || existing.regionales.some((id) => system.regionales.includes(id)))) {
|
|
24
|
+
throw new Error(`El conector ${name} repite un sistema o una regional que ya cubre otro conector.`);
|
|
25
|
+
}
|
|
26
|
+
systems.push(system);
|
|
27
|
+
}
|
|
28
|
+
return systems;
|
|
29
|
+
}
|
|
30
|
+
/** El conector del catálogo si está instalado y es la versión que espera este core; si no, null. */
|
|
31
|
+
async function loadFromCatalog(entry) {
|
|
32
|
+
const manifest = await installedManifest(entry);
|
|
33
|
+
if (!manifest)
|
|
34
|
+
return null;
|
|
35
|
+
if (manifest.version !== entry.version) {
|
|
36
|
+
logger.warn(`El conector de ${entry.name} instalado es la versión ${manifest.version} y esta versión de utn-mcp usa la ` +
|
|
37
|
+
`${entry.version}: corré \`utn-mcp login autogestion\` para actualizarlo.`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const system = validSystem(await import(pathToFileURL(join(connectorDir(entry), manifest.main ?? "index.js")).href), entry.package);
|
|
41
|
+
if (system.id !== entry.id)
|
|
42
|
+
throw new Error(`El paquete ${entry.package} no es el conector ${entry.id}.`);
|
|
43
|
+
return system;
|
|
44
|
+
}
|
|
45
|
+
function validSystem(module, source) {
|
|
46
|
+
const system = module.default;
|
|
47
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
|
|
48
|
+
if (!system ||
|
|
49
|
+
!nonEmpty(system.id) ||
|
|
50
|
+
!nonEmpty(system.name) ||
|
|
51
|
+
!Array.isArray(system.regionales) ||
|
|
52
|
+
system.regionales.length === 0 ||
|
|
53
|
+
!system.regionales.every(nonEmpty) ||
|
|
54
|
+
typeof system.create !== "function") {
|
|
55
|
+
throw new Error(`El paquete ${source} no exporta un conector de autogestión válido.`);
|
|
56
|
+
}
|
|
57
|
+
return system;
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AutogestionConnector, type AutogestionSystem } from "./connector.js";
|
|
2
|
+
import type { AutogestionSession } from "./session.js";
|
|
3
|
+
/**
|
|
4
|
+
* Sesión del modo local: un solo usuario, el de la compu. Recibe el sistema ya resuelto para la
|
|
5
|
+
* regional (la misma que eligió para el aula) y loguea el conector con las credenciales del
|
|
6
|
+
* keychain o las env vars, una vez por sistema y bajo demanda: el primer uso es el que pide login.
|
|
7
|
+
*/
|
|
8
|
+
export declare class LocalAutogestionSession implements AutogestionSession {
|
|
9
|
+
private readonly system;
|
|
10
|
+
private readonly regionalId;
|
|
11
|
+
/** Conectores ya logueados, por sistema: un login alcanza para todas las tools. */
|
|
12
|
+
private readonly connectors;
|
|
13
|
+
/** El sistema llega resuelto desde afuera: las plataformas no se conocen entre sí. */
|
|
14
|
+
constructor(system: AutogestionSystem, regionalId: string);
|
|
15
|
+
getConnector(): Promise<AutogestionConnector>;
|
|
16
|
+
private login;
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { connectorKit, LoginRequiredError } from "./connector.js";
|
|
2
|
+
import { loadAutogestionCredentials } from "./credentials.js";
|
|
3
|
+
/**
|
|
4
|
+
* Sesión del modo local: un solo usuario, el de la compu. Recibe el sistema ya resuelto para la
|
|
5
|
+
* regional (la misma que eligió para el aula) y loguea el conector con las credenciales del
|
|
6
|
+
* keychain o las env vars, una vez por sistema y bajo demanda: el primer uso es el que pide login.
|
|
7
|
+
*/
|
|
8
|
+
export class LocalAutogestionSession {
|
|
9
|
+
system;
|
|
10
|
+
regionalId;
|
|
11
|
+
/** Conectores ya logueados, por sistema: un login alcanza para todas las tools. */
|
|
12
|
+
connectors = new Map();
|
|
13
|
+
/** El sistema llega resuelto desde afuera: las plataformas no se conocen entre sí. */
|
|
14
|
+
constructor(system, regionalId) {
|
|
15
|
+
this.system = system;
|
|
16
|
+
this.regionalId = regionalId;
|
|
17
|
+
}
|
|
18
|
+
async getConnector() {
|
|
19
|
+
const system = this.system;
|
|
20
|
+
let connector = this.connectors.get(system.id);
|
|
21
|
+
if (!connector) {
|
|
22
|
+
connector = this.login(system.id, system.create(this.regionalId, connectorKit)).catch((error) => {
|
|
23
|
+
this.connectors.delete(system.id);
|
|
24
|
+
throw error;
|
|
25
|
+
});
|
|
26
|
+
this.connectors.set(system.id, connector);
|
|
27
|
+
}
|
|
28
|
+
return connector;
|
|
29
|
+
}
|
|
30
|
+
async login(systemId, connector) {
|
|
31
|
+
const credentials = await loadAutogestionCredentials(systemId);
|
|
32
|
+
if (!credentials)
|
|
33
|
+
throw new LoginRequiredError("la autogestión de tu regional");
|
|
34
|
+
await connector.login(credentials);
|
|
35
|
+
return connector;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AutogestionConnector } from "./connector.js";
|
|
2
|
+
/**
|
|
3
|
+
* La autogestión del usuario. Todo lo que habla con el sistema recibe esto y no sabe de dónde sale:
|
|
4
|
+
* `LocalAutogestionSession` (STDIO, el usuario de la compu). El modo HTTP todavía no la tiene
|
|
5
|
+
* resuelta: las credenciales por sistema de cada usuario quedan como decisión de diseño abierta.
|
|
6
|
+
*/
|
|
7
|
+
export interface AutogestionSession {
|
|
8
|
+
/**
|
|
9
|
+
* El conector del sistema que usa la regional del usuario, logueado y listo.
|
|
10
|
+
* Rechaza con LoginRequiredError si faltan las credenciales o con InvalidCredentialsError
|
|
11
|
+
* si el sistema las rechaza.
|
|
12
|
+
*/
|
|
13
|
+
getConnector(): Promise<AutogestionConnector>;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Una materia del estado académico, con lo que el sistema informa de ella. */
|
|
2
|
+
export interface EstadoMateria {
|
|
3
|
+
materia: string;
|
|
4
|
+
estado?: string;
|
|
5
|
+
plan?: string;
|
|
6
|
+
anio?: string;
|
|
7
|
+
nota?: string;
|
|
8
|
+
fecha?: string;
|
|
9
|
+
/** Celdas que no entraron en una columna conocida: no se tira información. */
|
|
10
|
+
extra?: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Una materia con las correlativas que exige, tal como las publica la autogestión. */
|
|
13
|
+
export interface CorrelatividadMateria {
|
|
14
|
+
materia: string;
|
|
15
|
+
/** Texto de la columna de correlativas, tal cual viene: "Puede cursar", "No aprobó X", etc. */
|
|
16
|
+
correlativas: string;
|
|
17
|
+
plan?: string;
|
|
18
|
+
anio?: string;
|
|
19
|
+
extra?: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Correlatividades de la carrera según la autogestión, separadas como las publica el sistema:
|
|
23
|
+
* `cursar` siempre; `rendir` cuando el sistema la tiene en otra página (Sysacad sí, verificado
|
|
24
|
+
* con cuenta real oct 2026). Es la fuente dinámica de las correlatividades en lugar de datos
|
|
25
|
+
* fijos del repo.
|
|
26
|
+
*/
|
|
27
|
+
export interface Correlatividades {
|
|
28
|
+
cursar: CorrelatividadMateria[];
|
|
29
|
+
rendir?: CorrelatividadMateria[];
|
|
30
|
+
}
|
|
31
|
+
export interface NotasParcialesMateria {
|
|
32
|
+
materia: string;
|
|
33
|
+
anio?: string;
|
|
34
|
+
comision?: string;
|
|
35
|
+
aula?: string;
|
|
36
|
+
horarios?: string;
|
|
37
|
+
notas?: string;
|
|
38
|
+
inasistencias?: string;
|
|
39
|
+
obs?: string;
|
|
40
|
+
cursando: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface ExamenMateria {
|
|
43
|
+
materia: string;
|
|
44
|
+
fecha?: string;
|
|
45
|
+
nota?: string;
|
|
46
|
+
especialidad?: string;
|
|
47
|
+
plan?: string;
|
|
48
|
+
codigo?: string;
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AutogestionSession } from "../client/session.js";
|
|
2
|
+
import type { Correlatividades } from "../client/types.js";
|
|
3
|
+
/** Correlatividades de cursar y, si el conector la publica, de rendir. */
|
|
4
|
+
export declare function getCorrelatividades(session: AutogestionSession): Promise<Correlatividades>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AutogestionSession } from "../client/session.js";
|
|
2
|
+
import type { EstadoMateria } from "../client/types.js";
|
|
3
|
+
/** Estado académico del alumno en su autogestión, delegado al conector de su regional. */
|
|
4
|
+
export declare function getEstadoAcademico(session: AutogestionSession): Promise<EstadoMateria[]>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { HttpError } from "../../shared/http.js";
|
|
2
|
+
export async function getExamenes(session) {
|
|
3
|
+
const connector = await session.getConnector();
|
|
4
|
+
if (!connector.examenes) {
|
|
5
|
+
throw new HttpError("El sistema de autogestión de tu regional todavía no tiene conector para el historial de exámenes.");
|
|
6
|
+
}
|
|
7
|
+
return connector.examenes();
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { HttpError } from "../../shared/http.js";
|
|
2
|
+
export async function getCursandoAhora(session) {
|
|
3
|
+
const connector = await session.getConnector();
|
|
4
|
+
if (!connector.notasParciales) {
|
|
5
|
+
throw new HttpError("El sistema de autogestión de tu regional todavía no tiene conector para la cursada actual.");
|
|
6
|
+
}
|
|
7
|
+
return (await connector.notasParciales()).filter((m) => m.cursando);
|
|
8
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `utn-mcp login autogestion`: instala el conector de la regional si falta o quedó viejo, pide legajo
|
|
3
|
+
* y contraseña, los prueba contra el sistema y recién ahí los guarda en el keychain. La misma regional
|
|
4
|
+
* que se usa para el aula decide qué sistema de autogestión toca.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runAutogestionLogin(regionalId: string): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Después del login del aula: si la regional tiene un conector en el catálogo, ofrece conectarlo.
|
|
9
|
+
* Un error acá no deshace el login del aula, que ya quedó guardado.
|
|
10
|
+
*/
|
|
11
|
+
export declare function offerAutogestionLogin(regionalId: string): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ask, askChoice, askHidden } from "../shared/input.js";
|
|
2
|
+
import { logger } from "../shared/logger.js";
|
|
3
|
+
import { catalogEntryForRegional } from "./client/catalog.js";
|
|
4
|
+
import { connectorKit, systemForRegional, UnsupportedRegionalError } from "./client/connector.js";
|
|
5
|
+
import { saveAutogestionCredentials } from "./client/credentials.js";
|
|
6
|
+
import { installConnector, installedManifest } from "./client/install.js";
|
|
7
|
+
import { loadInstalledSystems } from "./client/installed-systems.js";
|
|
8
|
+
/**
|
|
9
|
+
* `utn-mcp login autogestion`: instala el conector de la regional si falta o quedó viejo, pide legajo
|
|
10
|
+
* y contraseña, los prueba contra el sistema y recién ahí los guarda en el keychain. La misma regional
|
|
11
|
+
* que se usa para el aula decide qué sistema de autogestión toca.
|
|
12
|
+
*/
|
|
13
|
+
export async function runAutogestionLogin(regionalId) {
|
|
14
|
+
const entry = catalogEntryForRegional(regionalId);
|
|
15
|
+
if (entry && (await installedManifest(entry))?.version !== entry.version) {
|
|
16
|
+
logger.info(`Bajando el conector de ${entry.name}...`);
|
|
17
|
+
await installConnector(entry);
|
|
18
|
+
}
|
|
19
|
+
const system = systemForRegional(regionalId, await loadInstalledSystems());
|
|
20
|
+
if (!system)
|
|
21
|
+
throw new UnsupportedRegionalError(regionalId);
|
|
22
|
+
if (entry?.regionales[regionalId] === "candidate") {
|
|
23
|
+
logger.info(`El conector de ${system.name} todavía no se probó en tu regional: si algo no anda, contalo en ` +
|
|
24
|
+
"https://github.com/nialvape/utn-mcp/issues.");
|
|
25
|
+
}
|
|
26
|
+
const hint = system.usernameHint?.(regionalId);
|
|
27
|
+
const username = (await ask(`Legajo o usuario de ${system.name}${hint ? ` (${hint})` : ""}: `)).trim();
|
|
28
|
+
if (!username)
|
|
29
|
+
throw new Error("Falta el usuario.");
|
|
30
|
+
const password = await askHidden("Contraseña: ");
|
|
31
|
+
// Se guardan recién después de comprobar que el sistema las aceptó.
|
|
32
|
+
const connector = system.create(regionalId, connectorKit);
|
|
33
|
+
await connector.login({ username, password });
|
|
34
|
+
if (await saveAutogestionCredentials(system.id, { username, password })) {
|
|
35
|
+
logger.info("Credenciales guardadas en el keychain del sistema.");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
logger.warn("No hay keychain disponible: definí las variables de entorno del sistema (ver README).");
|
|
39
|
+
}
|
|
40
|
+
logger.info(`Listo: ${system.name} conectado.`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Después del login del aula: si la regional tiene un conector en el catálogo, ofrece conectarlo.
|
|
44
|
+
* Un error acá no deshace el login del aula, que ya quedó guardado.
|
|
45
|
+
*/
|
|
46
|
+
export async function offerAutogestionLogin(regionalId) {
|
|
47
|
+
const entry = catalogEntryForRegional(regionalId);
|
|
48
|
+
if (!entry)
|
|
49
|
+
return;
|
|
50
|
+
const options = ["Sí", "No, después con `utn-mcp login autogestion`"];
|
|
51
|
+
if ((await askChoice(`¿Conectar también ${entry.name} (estado académico, correlativas, finales)?`, options)) !== 0)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
await runAutogestionLogin(regionalId);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
logger.warn(`${e.message}\nEl aula quedó conectada; ${entry.name} se puede conectar después con \`utn-mcp login autogestion\`.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { getCorrelatividades } from "./format/correlatividades.js";
|
|
2
|
+
import { getEstadoAcademico } from "./format/estado-academico.js";
|
|
3
|
+
import { getExamenes } from "./format/examenes.js";
|
|
4
|
+
import { getCursandoAhora } from "./format/notas-parciales.js";
|
|
5
|
+
// JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
|
|
6
|
+
const json = (data) => ({
|
|
7
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
8
|
+
});
|
|
9
|
+
const failure = (e) => ({
|
|
10
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
11
|
+
isError: true,
|
|
12
|
+
});
|
|
13
|
+
export function registerAutogestionTools(server, session) {
|
|
14
|
+
server.registerTool("autogestion_estado_academico", {
|
|
15
|
+
description: "Estado académico del usuario según la autogestión de su regional: cada materia OBLIGATORIA " +
|
|
16
|
+
"del plan con su estado (regular, aprobada, libre), plan, nota y fecha. No es el catálogo " +
|
|
17
|
+
"completo de la carrera: puede incluir electivas aprobadas y no listar materias sin " +
|
|
18
|
+
"movimientos cargados. Las que se están cursando se marcan como Cursando cuando el " +
|
|
19
|
+
"sistema permite saberlas. Requiere " +
|
|
20
|
+
"`utn-mcp login autogestion` una vez; las credenciales son las del sistema de autogestión, " +
|
|
21
|
+
"que no siempre son las del aula virtual.",
|
|
22
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
23
|
+
}, async () => {
|
|
24
|
+
try {
|
|
25
|
+
return json(await getEstadoAcademico(session));
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
return failure(e);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
server.registerTool("autogestion_cursando_ahora", {
|
|
32
|
+
description: "Materias que el usuario está cursando ahora según la autogestión de su regional, con su " +
|
|
33
|
+
"comisión, aula y horarios cuando el sistema los publica. Incluye electivas, a diferencia " +
|
|
34
|
+
"del estado académico. Requiere `utn-mcp login autogestion` una vez.",
|
|
35
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
36
|
+
}, async () => {
|
|
37
|
+
try {
|
|
38
|
+
return json(await getCursandoAhora(session));
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return failure(e);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
server.registerTool("autogestion_examenes", {
|
|
45
|
+
description: "Historial de exámenes finales del usuario según la autogestión de su regional: fecha, " +
|
|
46
|
+
"materia, nota o estado del examen cuando todavía no hay nota, plan y código. Puede cubrir " +
|
|
47
|
+
"también materias rendidas en planes anteriores y electivas que el estado académico no " +
|
|
48
|
+
"muestra. Requiere `utn-mcp login autogestion` una vez.",
|
|
49
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
50
|
+
}, async () => {
|
|
51
|
+
try {
|
|
52
|
+
return json(await getExamenes(session));
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return failure(e);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
server.registerTool("autogestion_correlatividades", {
|
|
59
|
+
description: "Correlatividades de la carrera del usuario según la autogestión de su regional, separadas " +
|
|
60
|
+
"para cursar y para rendir cuando el sistema las distingue: para cada materia, qué exige " +
|
|
61
|
+
"tener regular o aprobada. Es la fuente dinámica de " +
|
|
62
|
+
"correlatividades: usala en lugar de suponer datos de un plan fijo. Requiere " +
|
|
63
|
+
"`utn-mcp login autogestion` una vez.",
|
|
64
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
65
|
+
}, async () => {
|
|
66
|
+
try {
|
|
67
|
+
return json(await getCorrelatividades(session));
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return failure(e);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
package/build/http.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type AuthInfo, type McpServerFactory } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { AccessPolicy } from "./oauth/access.js";
|
|
3
|
+
import type { RedirectAllowlist } from "./oauth/redirects.js";
|
|
4
|
+
import type { SessionStore } from "./shared/store/session-store.js";
|
|
5
|
+
import { type UploadInbox } from "./shared/uploads.js";
|
|
6
|
+
/** El usuario del store dueño del token, en `extra.userId`. */
|
|
7
|
+
export declare function userIdOf(authInfo: AuthInfo | undefined): number;
|
|
8
|
+
/** Dónde escucha el servidor y, si está publicado, desde qué dirección lo ven los clientes. */
|
|
9
|
+
export interface HttpOptions {
|
|
10
|
+
host: string;
|
|
11
|
+
port: number;
|
|
12
|
+
/** Sin barra final, por ejemplo `https://mcp.tu-dominio.com`. */
|
|
13
|
+
publicUrl?: string;
|
|
14
|
+
/** Quién puede conectarse por OAuth. */
|
|
15
|
+
access: AccessPolicy;
|
|
16
|
+
/** A dónde puede volver el navegador después del login de OAuth. */
|
|
17
|
+
redirects: RedirectAllowlist;
|
|
18
|
+
/** Con él se habilita la API de /admin (ver `mountAdmin`). */
|
|
19
|
+
adminSecret?: string;
|
|
20
|
+
}
|
|
21
|
+
/** El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión. */
|
|
22
|
+
export declare function startHttpServer(factory: McpServerFactory, options: HttpOptions, store: SessionStore, inbox: UploadInbox): void;
|
package/build/index.d.ts
ADDED
package/build/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { LocalSession } from "./aula-virtual/client/local-session.js";
|
|
|
6
6
|
import { defaultRegional } from "./aula-virtual/client/regionals.js";
|
|
7
7
|
import { StoredAulaSession } from "./aula-virtual/client/stored-session.js";
|
|
8
8
|
import { runLogin } from "./aula-virtual/login.js";
|
|
9
|
+
import { offerAutogestionLogin, runAutogestionLogin } from "./autogestion/login.js";
|
|
9
10
|
import { startHttpServer, userIdOf } from "./http.js";
|
|
10
11
|
import { accessPolicyFromEnv } from "./oauth/access.js";
|
|
11
12
|
import { redirectAllowlistFromEnv } from "./oauth/redirects.js";
|
|
@@ -18,6 +19,7 @@ const USAGE = `Uso:
|
|
|
18
19
|
utn-mcp --http [--host H] [--port N] servidor MCP por HTTP (por defecto 127.0.0.1:3000)
|
|
19
20
|
utn-mcp login guardar credenciales de la UTN y obtener el primer token
|
|
20
21
|
utn-mcp login --http lo mismo, para el modo HTTP: guarda la sesión y emite el token del cliente
|
|
22
|
+
utn-mcp login autogestion instalar el conector de la autogestión de tu regional y guardar legajo y contraseña
|
|
21
23
|
|
|
22
24
|
Si hay un .env en la carpeta actual, se lee al arrancar (ver .env.example).`;
|
|
23
25
|
async function main() {
|
|
@@ -38,7 +40,15 @@ async function main() {
|
|
|
38
40
|
// Una UTN_REGIONAL que no existe corta acá: si no, recién fallaría al primer login o al primer pedido.
|
|
39
41
|
defaultRegional();
|
|
40
42
|
if (positionals[0] === "login") {
|
|
41
|
-
|
|
43
|
+
if (positionals[1] === "autogestion") {
|
|
44
|
+
await runAutogestionLogin((await new LocalSession().getRegional()).id);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
await runLogin(values.http);
|
|
48
|
+
// La autogestión todavía es solo del modo local: en HTTP no hay dónde pedirle la clave a cada usuario.
|
|
49
|
+
if (!values.http)
|
|
50
|
+
await offerAutogestionLogin((await new LocalSession().getRegional()).id);
|
|
51
|
+
}
|
|
42
52
|
return;
|
|
43
53
|
}
|
|
44
54
|
if (values.http) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type AccessDecision = {
|
|
2
|
+
allowed: true;
|
|
3
|
+
} | {
|
|
4
|
+
allowed: false;
|
|
5
|
+
reason: string;
|
|
6
|
+
url?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Quién puede usar el servidor: decide con el usuario que acaba de entrar al aula de su regional. */
|
|
9
|
+
export type AccessPolicy = (user: {
|
|
10
|
+
regional: string;
|
|
11
|
+
username: string;
|
|
12
|
+
email: string | null;
|
|
13
|
+
}) => Promise<AccessDecision>;
|
|
14
|
+
/**
|
|
15
|
+
* La política sale del entorno: con UTN_MCP_ACCESS_URL decide otro servicio;
|
|
16
|
+
* con UTN_MCP_ALLOWED_EMAILS, una lista; sin ninguna, entra cualquiera con usuario de la UTN.
|
|
17
|
+
*/
|
|
18
|
+
export declare function accessPolicyFromEnv(env?: NodeJS.ProcessEnv): AccessPolicy;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Express } from "express";
|
|
2
|
+
import type { SessionStore } from "../shared/store/session-store.js";
|
|
3
|
+
/**
|
|
4
|
+
* API para que otro servicio del que hostea (un panel de cuenta, por ejemplo) liste usuarios, muestre y
|
|
5
|
+
* desconecte sus apps, consulte las regionales y dé de alta cuentas vinculando su aula. Solo
|
|
6
|
+
* existe con UTN_MCP_ADMIN_SECRET, que va como `Authorization: Bearer`. Los usuarios se buscan por email.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mountAdmin(app: Express, store: SessionStore, secret: string): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valores que viven en memoria hasta que se usan o vencen: los pedidos de `/authorize` mientras el
|
|
3
|
+
* usuario completa el login y los codes hasta que el cliente los canjea. Si el servidor se reinicia,
|
|
4
|
+
* se pierden y el cliente vuelve a empezar el OAuth, que es lo que haría igual con un code vencido.
|
|
5
|
+
*/
|
|
6
|
+
export declare class Expiring<T> {
|
|
7
|
+
private readonly ttlMs;
|
|
8
|
+
private readonly items;
|
|
9
|
+
/** `ttlMs` en milisegundos. */
|
|
10
|
+
constructor(ttlMs: number);
|
|
11
|
+
add(value: T): string;
|
|
12
|
+
get(id: string): T | undefined;
|
|
13
|
+
/** Lo devuelve y lo borra: un code se canjea una sola vez. */
|
|
14
|
+
take(id: string): T | undefined;
|
|
15
|
+
private sweep;
|
|
16
|
+
}
|
|
17
|
+
/** Cuenta intentos por clave (la IP) en una ventana fija. */
|
|
18
|
+
export declare class RateLimiter {
|
|
19
|
+
private readonly max;
|
|
20
|
+
private readonly windowMs;
|
|
21
|
+
private readonly hits;
|
|
22
|
+
/** `windowMs` en milisegundos. */
|
|
23
|
+
constructor(max: number, windowMs: number);
|
|
24
|
+
allow(key: string): boolean;
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MfaPrompt, SecondFactor } from "../aula-virtual/client/sso-login.js";
|
|
2
|
+
/**
|
|
3
|
+
* Un login que queda esperando el segundo factor entre un request y otro: corre en segundo plano y,
|
|
4
|
+
* cuando le pregunta algo al usuario, `next` lo devuelve para mostrárselo en una página; `answer` le
|
|
5
|
+
* pasa lo que el usuario contestó en el request siguiente.
|
|
6
|
+
*/
|
|
7
|
+
export declare class PendingLogin<T> {
|
|
8
|
+
private readonly run;
|
|
9
|
+
private waiting;
|
|
10
|
+
private listeners;
|
|
11
|
+
constructor(start: (secondFactor: SecondFactor) => Promise<T>);
|
|
12
|
+
/** Espera a que el login termine o le pregunte algo al usuario. */
|
|
13
|
+
next(): Promise<{
|
|
14
|
+
done: T;
|
|
15
|
+
} | {
|
|
16
|
+
prompt: MfaPrompt;
|
|
17
|
+
}>;
|
|
18
|
+
/** Contesta lo que está preguntando; si no pregunta nada (un formulario mandado dos veces), no hace nada. */
|
|
19
|
+
answer(value: string): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** A dónde puede volver el navegador después del login. */
|
|
2
|
+
export interface RedirectAllowlist {
|
|
3
|
+
/** Hostnames a los que se vuelve por HTTPS sin aviso. */
|
|
4
|
+
hosts: Set<string>;
|
|
5
|
+
/** Esquemas de apps instaladas, con los dos puntos (`cursor:`). */
|
|
6
|
+
schemes: Set<string>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Los conocidos más los de UTN_MCP_REDIRECT_ALLOW, separados por coma: hosts (`app.ejemplo.com`)
|
|
10
|
+
* o esquemas terminados en dos puntos (`miapp:`).
|
|
11
|
+
*/
|
|
12
|
+
export declare function redirectAllowlistFromEnv(env?: NodeJS.ProcessEnv): RedirectAllowlist;
|
|
13
|
+
/**
|
|
14
|
+
* `trusted`: loopback, un host HTTPS de la lista o el esquema de una app de la lista.
|
|
15
|
+
* `unknown`: cualquier otro host HTTPS, como el de un agente propio; se acepta con aviso.
|
|
16
|
+
* `rejected`: el resto.
|
|
17
|
+
*/
|
|
18
|
+
export declare function redirectTrust(uri: unknown, allow: RedirectAllowlist): "trusted" | "unknown" | "rejected";
|
|
19
|
+
/** Cómo se le nombra al usuario el destino. */
|
|
20
|
+
export declare function describeRedirect(uri: string): string;
|