utn-mcp 0.8.2 → 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.
Files changed (87) hide show
  1. package/README.md +29 -2
  2. package/build/aula-virtual/client/client.d.ts +97 -0
  3. package/build/aula-virtual/client/client.js +21 -0
  4. package/build/aula-virtual/client/credentials.d.ts +10 -0
  5. package/build/aula-virtual/client/link.d.ts +21 -0
  6. package/build/aula-virtual/client/local-session.d.ts +23 -0
  7. package/build/aula-virtual/client/moodle-api.d.ts +25 -0
  8. package/build/aula-virtual/client/moodle-api.js +28 -4
  9. package/build/aula-virtual/client/regionals.d.ts +47 -0
  10. package/build/aula-virtual/client/session.d.ts +12 -0
  11. package/build/aula-virtual/client/sso-login.d.ts +60 -0
  12. package/build/aula-virtual/client/stored-session.d.ts +32 -0
  13. package/build/aula-virtual/client/types.d.ts +173 -0
  14. package/build/aula-virtual/format/actions.d.ts +57 -0
  15. package/build/aula-virtual/format/activities.d.ts +86 -0
  16. package/build/aula-virtual/format/address.d.ts +36 -0
  17. package/build/aula-virtual/format/address.js +11 -0
  18. package/build/aula-virtual/format/fields.d.ts +11 -0
  19. package/build/aula-virtual/format/files.d.ts +32 -0
  20. package/build/aula-virtual/format/files.js +2 -2
  21. package/build/aula-virtual/format/navigate.d.ts +213 -0
  22. package/build/aula-virtual/format/study-pack.d.ts +33 -0
  23. package/build/aula-virtual/format/study-pack.js +199 -0
  24. package/build/aula-virtual/format/submission.d.ts +40 -0
  25. package/build/aula-virtual/login.d.ts +6 -0
  26. package/build/aula-virtual/tools.d.ts +4 -0
  27. package/build/aula-virtual/tools.js +40 -0
  28. package/build/autogestion/client/catalog.d.ts +20 -0
  29. package/build/autogestion/client/catalog.js +22 -0
  30. package/build/autogestion/client/connector.d.ts +61 -0
  31. package/build/autogestion/client/connector.js +31 -0
  32. package/build/autogestion/client/credentials.d.ts +5 -0
  33. package/build/autogestion/client/credentials.js +40 -0
  34. package/build/autogestion/client/install.d.ts +14 -0
  35. package/build/autogestion/client/install.js +72 -0
  36. package/build/autogestion/client/installed-systems.d.ts +7 -0
  37. package/build/autogestion/client/installed-systems.js +58 -0
  38. package/build/autogestion/client/local-session.d.ts +17 -0
  39. package/build/autogestion/client/local-session.js +37 -0
  40. package/build/autogestion/client/session.d.ts +14 -0
  41. package/build/autogestion/client/session.js +1 -0
  42. package/build/autogestion/client/types.d.ts +49 -0
  43. package/build/autogestion/client/types.js +1 -0
  44. package/build/autogestion/format/correlatividades.d.ts +4 -0
  45. package/build/autogestion/format/correlatividades.js +4 -0
  46. package/build/autogestion/format/estado-academico.d.ts +4 -0
  47. package/build/autogestion/format/estado-academico.js +4 -0
  48. package/build/autogestion/format/examenes.d.ts +3 -0
  49. package/build/autogestion/format/examenes.js +8 -0
  50. package/build/autogestion/format/notas-parciales.d.ts +3 -0
  51. package/build/autogestion/format/notas-parciales.js +8 -0
  52. package/build/autogestion/login.d.ts +11 -0
  53. package/build/autogestion/login.js +59 -0
  54. package/build/autogestion/tools.d.ts +3 -0
  55. package/build/autogestion/tools.js +73 -0
  56. package/build/http.d.ts +22 -0
  57. package/build/index.d.ts +2 -0
  58. package/build/index.js +11 -1
  59. package/build/oauth/access.d.ts +18 -0
  60. package/build/oauth/admin.d.ts +8 -0
  61. package/build/oauth/admin.js +51 -10
  62. package/build/oauth/expiring.d.ts +25 -0
  63. package/build/oauth/pending-login.d.ts +20 -0
  64. package/build/oauth/redirects.d.ts +20 -0
  65. package/build/oauth/server.d.ts +22 -0
  66. package/build/server.d.ts +9 -0
  67. package/build/server.js +21 -2
  68. package/build/shared/documents/index.d.ts +33 -0
  69. package/build/shared/documents/merge.d.ts +32 -0
  70. package/build/shared/documents/merge.js +197 -0
  71. package/build/shared/documents/office.d.ts +9 -0
  72. package/build/shared/documents/pdf.d.ts +2 -0
  73. package/build/shared/documents/png.d.ts +2 -0
  74. package/build/shared/documents/zip.d.ts +18 -0
  75. package/build/shared/html.d.ts +6 -0
  76. package/build/shared/html.js +7 -1
  77. package/build/shared/http.d.ts +6 -0
  78. package/build/shared/input.d.ts +7 -0
  79. package/build/shared/load-env.d.ts +1 -0
  80. package/build/shared/logger.d.ts +5 -0
  81. package/build/shared/pages.d.ts +6 -0
  82. package/build/shared/paths.d.ts +10 -0
  83. package/build/shared/paths.js +4 -0
  84. package/build/shared/store/session-store.d.ts +77 -0
  85. package/build/shared/store/sqlite-store.d.ts +36 -0
  86. package/build/shared/uploads.d.ts +62 -0
  87. package/package.json +9 -1
@@ -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
+ /** Correlatividades de cursar y, si el conector la publica, de rendir. */
2
+ export async function getCorrelatividades(session) {
3
+ return (await session.getConnector()).correlatividades();
4
+ }
@@ -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,4 @@
1
+ /** Estado académico del alumno en su autogestión, delegado al conector de su regional. */
2
+ export async function getEstadoAcademico(session) {
3
+ return (await session.getConnector()).estadoAcademico();
4
+ }
@@ -0,0 +1,3 @@
1
+ import type { AutogestionSession } from "../client/session.js";
2
+ import type { ExamenMateria } from "../client/types.js";
3
+ export declare function getExamenes(session: AutogestionSession): Promise<ExamenMateria[]>;
@@ -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,3 @@
1
+ import type { AutogestionSession } from "../client/session.js";
2
+ import type { NotasParcialesMateria } from "../client/types.js";
3
+ export declare function getCursandoAhora(session: AutogestionSession): Promise<NotasParcialesMateria[]>;
@@ -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,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import type { AutogestionSession } from "./client/session.js";
3
+ export declare function registerAutogestionTools(server: McpServer, session: AutogestionSession): void;
@@ -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
+ }
@@ -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;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "./shared/load-env.js";
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
- await runLogin(values.http);
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;
@@ -2,13 +2,16 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import express from "express";
3
3
  import { linkAulaAccount } from "../aula-virtual/client/link.js";
4
4
  import { availableRegionals } from "../aula-virtual/client/regionals.js";
5
- import { InvalidCredentialsError, MfaError, MfaRequiredError } from "../aula-virtual/client/sso-login.js";
5
+ import { InvalidCredentialsError, MfaError } from "../aula-virtual/client/sso-login.js";
6
6
  import { AULA_PLATFORM } from "../aula-virtual/client/stored-session.js";
7
7
  import { logger } from "../shared/logger.js";
8
- import { RateLimiter } from "./expiring.js";
8
+ import { Expiring, RateLimiter } from "./expiring.js";
9
+ import { PendingLogin } from "./pending-login.js";
9
10
  /** Intentos de `/admin/verificar` por cuenta del aula cada 15 minutos: que no sirva para probar contraseñas. */
10
11
  const VERIFY_ATTEMPTS = 5;
11
12
  const VERIFY_WINDOW_MS = 15 * 60_000;
13
+ /** Cuánto tiene el usuario para completar el segundo factor, en milisegundos: lo mismo que en el login de OAuth. */
14
+ const SECOND_FACTOR_TTL_MS = 10 * 60_000;
12
15
  const SUPPORT = { verified: "verificada", candidate: "candidata", unsupported: "sin_soporte" };
13
16
  /** Segundos desde epoch, como los guarda el store, a ISO. */
14
17
  const isoDate = (seconds) => new Date(seconds * 1000).toISOString();
@@ -20,6 +23,7 @@ const isoDate = (seconds) => new Date(seconds * 1000).toISOString();
20
23
  export function mountAdmin(app, store, secret) {
21
24
  const json = express.json({ limit: "4kb" });
22
25
  const verifyLimiter = new RateLimiter(VERIFY_ATTEMPTS, VERIFY_WINDOW_MS);
26
+ const secondFactors = new Expiring(SECOND_FACTOR_TTL_MS);
23
27
  const requireSecret = (req, res, next) => {
24
28
  const got = Buffer.from(req.headers.authorization ?? "");
25
29
  const want = Buffer.from(`Bearer ${secret}`);
@@ -79,6 +83,7 @@ export function mountAdmin(app, store, secret) {
79
83
  * `{ regional, usuario, contrasena }` → entra al aula como el login de OAuth: crea el usuario si no
80
84
  * existe y le guarda la sesión, así ya puede usar las tools. Devuelve `{ usuario, nombre, email,
81
85
  * primero? }`, con `primero` si fue el primero en verificar la regional; si no entra, `{ error, motivo }`.
86
+ * Si Microsoft pide MFA, devuelve `{ segundoFactor }` y el login sigue con `/admin/segundo-factor`.
82
87
  */
83
88
  app.post("/admin/verificar", requireSecret, json, async (req, res) => {
84
89
  const id = typeof req.body?.regional === "string" ? req.body.regional : "";
@@ -94,8 +99,33 @@ export function mountAdmin(app, store, secret) {
94
99
  if (!verifyLimiter.allow(`${regional.id}:${username.toLowerCase()}`)) {
95
100
  return void res.status(429).json({ error: "intentos", motivo: "Demasiados intentos. Esperá unos minutos." });
96
101
  }
102
+ const flow = new PendingLogin((secondFactor) => linkAulaAccount(store, regional, { username, password }, secondFactor));
103
+ await advance(res, { flow, regional, username });
104
+ });
105
+ /**
106
+ * `{ login, respuesta }` → contesta lo que pidió el segundo factor: el `id` del método, el código o
107
+ * cualquier cosa cuando el usuario ya aprobó. Devuelve lo mismo que `/admin/verificar`.
108
+ */
109
+ app.post("/admin/segundo-factor", requireSecret, json, async (req, res) => {
110
+ const id = typeof req.body?.login === "string" ? req.body.login : "";
111
+ const verification = id ? secondFactors.get(id) : undefined;
112
+ if (!verification) {
113
+ return void res.status(410).json({ error: "vencido", motivo: "Pasó demasiado tiempo. Volvé a iniciar sesión." });
114
+ }
115
+ verification.flow.answer(typeof req.body?.respuesta === "string" ? req.body.respuesta : "");
116
+ await advance(res, verification, id);
117
+ });
118
+ /** Sigue el login hasta que termina o pide el segundo factor; `id` es el de uno que ya estaba esperando. */
119
+ async function advance(res, verification, id) {
120
+ const { flow, regional, username } = verification;
97
121
  try {
98
- const account = await linkAulaAccount(store, regional, { username, password });
122
+ const step = await flow.next();
123
+ if ("prompt" in step) {
124
+ return void res.json({ segundoFactor: secondFactorJson(id ?? secondFactors.add(verification), step.prompt) });
125
+ }
126
+ if (id)
127
+ secondFactors.take(id);
128
+ const account = step.done;
99
129
  logger.info(`Admin: ${account.username} (${regional.id}) vinculó su aula.`);
100
130
  res.json({
101
131
  usuario: account.username,
@@ -105,17 +135,28 @@ export function mountAdmin(app, store, secret) {
105
135
  });
106
136
  }
107
137
  catch (e) {
138
+ if (id)
139
+ secondFactors.take(id);
108
140
  if (e instanceof InvalidCredentialsError) {
109
141
  return void res.status(401).json({ error: "credenciales", motivo: "El aula rechazó el usuario o la contraseña." });
110
142
  }
111
- if (e instanceof MfaRequiredError || e instanceof MfaError) {
112
- return void res.status(422).json({
113
- error: "mfa",
114
- motivo: "La cuenta de Microsoft pide un segundo factor (MFA): que entre por la página de login.",
115
- });
116
- }
143
+ if (e instanceof MfaError)
144
+ return void res.status(422).json({ error: "mfa", motivo: e.message });
117
145
  logger.warn(`Verificación de ${username} (${regional.id}): ${e.message}`);
118
146
  res.status(502).json({ error: "aula", motivo: `No pudimos entrar al aula de ${regional.name}.` });
119
147
  }
120
- });
148
+ }
149
+ }
150
+ /**
151
+ * Lo que pide el segundo factor, para que el panel se lo muestre al usuario. `login` es el id con el que
152
+ * se contesta; `paso` es "elegir" (con `metodos`), "codigo" (con `reintento` si el anterior estaba mal)
153
+ * o "aprobar" (con `numero` si Authenticator pide tipearlo).
154
+ */
155
+ function secondFactorJson(login, prompt) {
156
+ if (prompt.step === "choose") {
157
+ return { login, paso: "elegir", metodos: prompt.methods.map((m) => ({ id: m.id, nombre: m.label })) };
158
+ }
159
+ if (prompt.step === "code")
160
+ return { login, paso: "codigo", metodo: prompt.method.label, reintento: prompt.retry || undefined };
161
+ return { login, paso: "aprobar", metodo: prompt.method.label, numero: prompt.number };
121
162
  }
@@ -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;
@@ -0,0 +1,22 @@
1
+ import { type Express, type RequestHandler } from "express";
2
+ import type { SessionStore } from "../shared/store/session-store.js";
3
+ import type { AccessPolicy } from "./access.js";
4
+ import { type RedirectAllowlist } from "./redirects.js";
5
+ export interface OAuthOptions {
6
+ /** URL base del servidor, sin barra final: es el issuer y de ahí salen los endpoints. */
7
+ issuer: string;
8
+ store: SessionStore;
9
+ access: AccessPolicy;
10
+ /** Rechaza los POST de formularios que vengan de otro origen. */
11
+ sameOrigin: RequestHandler;
12
+ /** A dónde puede volver el navegador con el code. */
13
+ redirects: RedirectAllowlist;
14
+ }
15
+ /**
16
+ * Servidor de autorización OAuth 2.1 para los clientes MCP: registro dinámico, PKCE obligatorio y
17
+ * clientes públicos. El usuario se identifica con la UTN, y ese mismo login vincula su aula: la
18
+ * contraseña se usa una vez y no se guarda. Devuelve la URL de la metadata del recurso, que va en el 401.
19
+ */
20
+ export declare function mountOAuth(app: Express, { issuer, store, access, sameOrigin, redirects }: OAuthOptions): string;
21
+ /** Los endpoints que llaman los clientes desde el navegador (como el MCP Inspector) aceptan cualquier origen. */
22
+ export declare const cors: RequestHandler;
@@ -0,0 +1,9 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import type { AulaSession } from "./aula-virtual/client/session.js";
3
+ import type { FileSource } from "./shared/uploads.js";
4
+ /**
5
+ * Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
6
+ * `files`: de dónde salen los archivos que el usuario sube a una plataforma.
7
+ * Es async porque, en local, resuelve la regional y los conectores instalados antes de registrar la autogestión.
8
+ */
9
+ export declare function createServer(aula: AulaSession, files: FileSource): Promise<McpServer>;