utn-mcp 0.9.0 → 0.9.2

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/LICENSE CHANGED
@@ -1,15 +1,15 @@
1
- ISC License
2
-
3
- Copyright (c) 2026 Joaquín Peñalva
4
-
5
- Permission to use, copy, modify, and/or distribute this software for any
6
- purpose with or without fee is hereby granted, provided that the above
7
- copyright notice and this permission notice appear in all copies.
8
-
9
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
- ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
- ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
- OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Joaquín Peñalva
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -19,6 +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_grades` | Tus calificaciones en un curso, incluidas las que el docente carga a mano. |
22
23
  | `aula_study_pack` | Une en un solo PDF con índice los apuntes que elijas de una materia (un 'machacado'), guardado en tu compu. Solo en modo local. |
23
24
  | `aula_get_site_info` | Datos de tu usuario y de tu regional en el aula virtual. |
24
25
  | `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
@@ -29,12 +30,12 @@ El agente recorre el aula como lo harías vos: entra al curso, mira los foros y
29
30
  | `aula_choice_answer` | Responde una elección o te anota en un grupo. |
30
31
  | `aula_send_message` | Manda un mensaje privado a un docente o compañero. |
31
32
  | `aula_calendar_add_event` | Agrega un recordatorio a tu calendario del aula. |
32
- | `autogestion_cursando_ahora` | Las materias que estás cursando ahora según la autogestión, con comisión, aula y horarios cuando el sistema los publica. Incluye electivas. Requiere `utn-mcp login autogestion`. Aparece solo en modo local y si tu regional tiene un conector instalado. |
33
- | `autogestion_estado_academico` | Tu estado académico según la autogestión de tu regional: cada materia con su estado, plan, nota y fecha. Requiere `utn-mcp login autogestion`. Aparece solo en modo local y si tu regional tiene un conector instalado. |
34
- | `autogestion_correlatividades` | Las correlatividades de tu carrera según la autogestión, para cursar y para rendir, en vez de datos fijos del repo. Requiere `utn-mcp login autogestion`. Aparece solo en modo local y si tu regional tiene un conector instalado. |
35
- | `autogestion_examenes` | Tu historial de finales según la autogestión: fecha, materia, nota o estado, plan y código. Requiere `utn-mcp login autogestion`. Aparece solo en modo local y si tu regional tiene un conector instalado. |
33
+ | `autogestion_cursando_ahora` | Las materias que estás cursando ahora según la autogestión, con comisión, aula y horarios cuando el sistema los publica. Incluye electivas. Aparece solo si tu regional tiene un conector de autogestión. |
34
+ | `autogestion_estado_academico` | Tu estado académico según la autogestión de tu regional: cada materia con su estado, plan, nota y fecha. Aparece solo si tu regional tiene un conector de autogestión. |
35
+ | `autogestion_correlatividades` | Las correlatividades de tu carrera según la autogestión, para cursar y para rendir, en vez de datos fijos del repo. Aparece solo si tu regional tiene un conector de autogestión. |
36
+ | `autogestion_examenes` | Tu historial de finales según la autogestión: fecha, materia, nota o estado, plan y código. Aparece solo si tu regional tiene un conector de autogestión. |
36
37
 
37
- Al abrir una tarea, `aula_open` muestra si la entregaste, cuándo vence y la devolución con la nota. Las tools que escriben le piden al agente que te muestre lo que va a hacer y espere tu confirmación.
38
+ Al abrir una tarea, `aula_open` muestra si la entregaste, cuándo vence y la devolución con la nota. Al abrir un cuestionario, muestra tus intentos y la nota final. Las tools que escriben le piden al agente que te muestre lo que va a hacer y espere tu confirmación.
38
39
 
39
40
  ### Archivos que puede leer
40
41
  - **PDF**: el texto, página por página. Los **escaneados** se mandan como imagen de cada página, y el agente los lee igual.
@@ -143,7 +144,7 @@ Si tu regional usa el Sysacad clásico, al terminar `login` te pregunta si quer
143
144
  ```bash
144
145
  npx -y utn-mcp login autogestion
145
146
  ```
146
- Con Sysacad conectado aparecen las tools `autogestion_*`: tu estado académico, lo que estás cursando, tus finales y las correlatividades de tu carrera. Por ahora son solo de lectura y funcionan solo con el servidor instalado en tu computadora.
147
+ Con Sysacad conectado aparecen las tools `autogestion_*`: tu estado académico, lo que estás cursando, tus finales y las correlatividades de tu carrera. Por ahora son solo de lectura.
147
148
 
148
149
  Lo usan Bahía Blanca, Chubut, Concordia, Delta, General Pacheco, Haedo, La Rioja, Mar del Plata, Neuquén, Paraná, Reconquista, Santa Fe, San Francisco, San Nicolás, San Rafael, Tucumán, Tierra del Fuego y Venado Tuerto. En Tucumán anduvo con una versión anterior del conector; esta todavía no se probó con una cuenta real: si lo probás, contanos en un [issue](https://github.com/nialvape/utn-mcp/issues) si funcionó. En algunas el usuario no es el legajo tal cual (en Delta va sin el "23-", por ejemplo); el login te dice qué escribir. Avellaneda y Rosario quedan afuera porque su Sysacad pide un captcha o un desafío del navegador, y Rafaela, Santa Cruz y Trenque Lauquen porque no tienen HTTPS válido y la contraseña viajaría expuesta.
149
150
 
@@ -163,6 +164,8 @@ En este modo el servidor no lee archivos de tu disco: para entregar, el agente p
163
164
  ### Hostearlo para otros
164
165
  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.
165
166
 
167
+ La autogestión (Sysacad) funciona igual para cada alumno, según su regional. Al arrancar, el servidor instala los conectores del catálogo en `connectors/` de su carpeta de datos. La primera vez que el agente la necesita, le pasa al alumno un link a `/autogestion/...` para que entre con su legajo y contraseña de Sysacad desde el navegador: la contraseña nunca pasa por el chat. Como con el aula, la contraseña se usa una sola vez y **no se guarda**: queda cifrada en la base solo la sesión de Sysacad. Cuando Sysacad la vence (unos minutos sin usarla), el agente le vuelve a pasar el link.
168
+
166
169
  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.
167
170
 
168
171
  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)):
@@ -193,11 +196,11 @@ Instalado en tu computadora, todo queda ahí:
193
196
  - **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
194
197
  - **Token del aula virtual**: en `%APPDATA%\utn-mcp\session.json` (Windows) o `~/.config/utn-mcp/session.json` (Linux y macOS).
195
198
  - **Conector de autogestión**: en `connectors/`, dentro de esa misma carpeta. El legajo y la contraseña de Sysacad van al gestor de credenciales, aparte de los del aula.
196
- - **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.
199
+ - **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. Con la de Sysacad pasa lo mismo: no se guarda, y su sesión va cifrada en la misma base.
197
200
 
198
201
  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`. Para Sysacad son `SYSACAD_ASP_USER` y `SYSACAD_ASP_PASS`.
199
202
 
200
- 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.
203
+ Si usás un servidor hosteado por otra persona, tus sesiones del aula y de Sysacad las guarda ese servidor (cifradas, y sin tus contraseñas): revisá su política de privacidad.
201
204
 
202
205
  ## Desarrollo
203
206
  ```bash
@@ -1,5 +1,5 @@
1
1
  import type { AulaSession } from "./session.js";
2
- import type { Assignment, CourseModuleContent, AssignSubmissionStatus, Choice, ChoiceOption, Course, CourseSection, ForumDiscussion, ForumPost, SiteInfo } from "./types.js";
2
+ import type { Assignment, CourseModuleContent, AssignSubmissionStatus, Choice, ChoiceOption, Course, CourseSection, ForumDiscussion, ForumPost, GradeItem, Quiz, QuizAttempt, SiteInfo } from "./types.js";
3
3
  export declare function getSiteInfo(session: AulaSession): Promise<SiteInfo>;
4
4
  /** El usuario dueño del token, con su email institucional (site_info no lo trae). */
5
5
  export declare function getOwnUser(session: AulaSession): Promise<{
@@ -72,6 +72,18 @@ export declare function submitForGrading(session: AulaSession, assignmentid: num
72
72
  * mod_assign_save_submission rechaza una entrega vacía.
73
73
  */
74
74
  export declare function removeSubmission(session: AulaSession, assignid: number): Promise<void>;
75
+ export declare function getQuizzes(session: AulaSession, courseId: number): Promise<Quiz[]>;
76
+ /** Los intentos del usuario, terminados o no, sin las vistas previas. */
77
+ export declare function getQuizAttempts(session: AulaSession, quizid: number): Promise<QuizAttempt[]>;
78
+ /** La nota final del cuestionario, ya en su escala (`Quiz.grade`); null si todavía no hay o está oculta. */
79
+ export declare function getQuizBestGrade(session: AulaSession, quizid: number): Promise<number | null>;
80
+ /** El comentario general que el docente configuró para el rango de esa nota. */
81
+ export declare function getQuizFeedback(session: AulaSession, quizid: number, grade: number): Promise<string>;
82
+ /**
83
+ * El libro de calificaciones del usuario en un curso, como lo ve en "Calificaciones". Trae también
84
+ * las notas que el docente carga a mano, que no están en ningún módulo.
85
+ */
86
+ export declare function getCourseGradeItems(session: AulaSession, courseid: number): Promise<GradeItem[]>;
75
87
  export declare function getChoices(session: AulaSession, courseId: number): Promise<Choice[]>;
76
88
  export declare function getChoiceOptions(session: AulaSession, choiceid: number): Promise<ChoiceOption[]>;
77
89
  export declare function submitChoiceResponse(session: AulaSession, choiceid: number, optionIds: number[]): Promise<void>;
@@ -149,6 +149,45 @@ export async function removeSubmission(session, assignid) {
149
149
  });
150
150
  throwOnWarnings(warnings, "Moodle no quitó la entrega.");
151
151
  }
152
+ // --- Cuestionarios ---
153
+ export async function getQuizzes(session, courseId) {
154
+ const { quizzes } = await callWs(session, "mod_quiz_get_quizzes_by_courses", {
155
+ "courseids[0]": courseId,
156
+ });
157
+ return quizzes;
158
+ }
159
+ /** Los intentos del usuario, terminados o no, sin las vistas previas. */
160
+ export async function getQuizAttempts(session, quizid) {
161
+ const { attempts } = await callWs(session, "mod_quiz_get_user_attempts", {
162
+ quizid,
163
+ status: "all",
164
+ includepreviews: 0,
165
+ });
166
+ return attempts;
167
+ }
168
+ /** La nota final del cuestionario, ya en su escala (`Quiz.grade`); null si todavía no hay o está oculta. */
169
+ export async function getQuizBestGrade(session, quizid) {
170
+ const { hasgrade, grade } = await callWs(session, "mod_quiz_get_user_best_grade", { quizid });
171
+ return hasgrade ? (grade ?? null) : null;
172
+ }
173
+ /** El comentario general que el docente configuró para el rango de esa nota. */
174
+ export async function getQuizFeedback(session, quizid, grade) {
175
+ const { feedbacktext } = await callWs(session, "mod_quiz_get_quiz_feedback_for_grade", {
176
+ quizid,
177
+ grade,
178
+ });
179
+ return feedbacktext;
180
+ }
181
+ // --- Calificaciones ---
182
+ /**
183
+ * El libro de calificaciones del usuario en un curso, como lo ve en "Calificaciones". Trae también
184
+ * las notas que el docente carga a mano, que no están en ningún módulo.
185
+ */
186
+ export async function getCourseGradeItems(session, courseid) {
187
+ const { userid } = await getSiteInfo(session);
188
+ const { usergrades } = await callWs(session, "gradereport_user_get_grade_items", { courseid, userid });
189
+ return usergrades[0]?.gradeitems ?? [];
190
+ }
152
191
  // --- Elecciones (choice) y selección de grupos (choicegroup) ---
153
192
  export async function getChoices(session, courseId) {
154
193
  const { choices } = await callWs(session, "mod_choice_get_choices_by_courses", {
@@ -171,3 +171,38 @@ export interface ChoiceOption {
171
171
  checked?: boolean;
172
172
  disabled?: boolean;
173
173
  }
174
+ export interface Quiz {
175
+ id: number;
176
+ coursemodule: number;
177
+ /** Nota máxima del cuestionario: a esta escala se lleva el puntaje de los intentos. */
178
+ grade: number;
179
+ /** Puntaje máximo sumando las preguntas; un intento suma sobre esta escala. */
180
+ sumgrades: number;
181
+ decimalpoints: number;
182
+ /** 0: intentos ilimitados. */
183
+ attempts: number;
184
+ /** En segundos; 0 es sin límite. */
185
+ timelimit: number;
186
+ hasfeedback: number;
187
+ }
188
+ export interface QuizAttempt {
189
+ attempt: number;
190
+ state: "inprogress" | "overdue" | "finished" | "abandoned" | string;
191
+ timestart: number;
192
+ timefinish: number;
193
+ /** Puntaje crudo; null si todavía no está corregido o las opciones de revisión lo ocultan. */
194
+ sumgrades: number | null;
195
+ }
196
+ export interface GradeItem {
197
+ itemname: string | null;
198
+ /** "mod", "manual", "category" o "course" (el total del curso). */
199
+ itemtype: string;
200
+ itemmodule: string | null;
201
+ cmid?: number;
202
+ /** Ya formateada por Moodle con la configuración del curso ("4,00", "-" si no hay nota). */
203
+ gradeformatted: string;
204
+ grademax: number;
205
+ percentageformatted?: string;
206
+ gradeishidden: boolean;
207
+ feedback?: string;
208
+ }
@@ -51,6 +51,20 @@ export declare function viewSubmission(session: AulaSession, courseId: number, m
51
51
  }[] | undefined;
52
52
  texto: string | undefined;
53
53
  }>;
54
+ /** Los intentos del usuario y la nota final del cuestionario, en su escala ("0,00 / 10,00"). */
55
+ export declare function viewQuiz(session: AulaSession, courseId: number, module: CourseModule): Promise<{
56
+ calificacionFinal: string | undefined;
57
+ devolucion: string | undefined;
58
+ intentosPermitidos: string | number | undefined;
59
+ limiteTiempoMinutos: number | undefined;
60
+ intentos: {
61
+ numero: number;
62
+ estado: string;
63
+ comienzo: string | null;
64
+ fin: string | undefined;
65
+ calificacion: string | undefined;
66
+ }[];
67
+ }>;
54
68
  export declare function viewChoice(session: AulaSession, courseId: number, module: CourseModule): Promise<{
55
69
  multiple: true | undefined;
56
70
  puedeCambiar: boolean | undefined;
@@ -1,5 +1,5 @@
1
1
  import { htmlToText } from "../../shared/html.js";
2
- import { getAssignments, getChoiceOptions, getChoicegroupOptions, getChoices, getSubmissionStatus, } from "../client/client.js";
2
+ import { getAssignments, getChoiceOptions, getChoicegroupOptions, getChoices, getQuizAttempts, getQuizBestGrade, getQuizFeedback, getQuizzes, getSubmissionStatus, } from "../client/client.js";
3
3
  import { isoDate, toFile } from "./fields.js";
4
4
  const SUBMISSION_STATUS_LABELS = {
5
5
  new: "sin entregar",
@@ -71,6 +71,41 @@ function pluginContent(plugins = []) {
71
71
  texto: texts.length ? texts.join("\n\n") : undefined,
72
72
  };
73
73
  }
74
+ const QUIZ_STATE_LABELS = {
75
+ inprogress: "en curso",
76
+ overdue: "vencido sin enviar",
77
+ finished: "finalizado",
78
+ abandoned: "abandonado",
79
+ };
80
+ /** Una nota con los decimales del cuestionario y coma decimal, como la muestra el aula ("0,00"). */
81
+ const formatGrade = (grade, decimals) => grade.toFixed(decimals).replace(".", ",");
82
+ /** Los intentos del usuario y la nota final del cuestionario, en su escala ("0,00 / 10,00"). */
83
+ export async function viewQuiz(session, courseId, module) {
84
+ const [quizzes, attempts, best] = await Promise.all([
85
+ getQuizzes(session, courseId),
86
+ getQuizAttempts(session, module.instance),
87
+ getQuizBestGrade(session, module.instance),
88
+ ]);
89
+ const quiz = quizzes.find((q) => q.coursemodule === module.id);
90
+ const decimals = quiz?.decimalpoints ?? 2;
91
+ const outOf = (grade) => quiz ? `${formatGrade(grade, decimals)} / ${formatGrade(quiz.grade, decimals)}` : formatGrade(grade, decimals);
92
+ // El intento suma sobre el puntaje de las preguntas; la nota se lleva a la escala del cuestionario.
93
+ const attemptGrade = (sumgrades) => sumgrades === null || !quiz?.sumgrades ? undefined : outOf((sumgrades / quiz.sumgrades) * quiz.grade);
94
+ const feedback = quiz?.hasfeedback && best !== null ? htmlToText(await getQuizFeedback(session, quiz.id, best)) : "";
95
+ return {
96
+ calificacionFinal: best !== null ? outOf(best) : undefined,
97
+ devolucion: feedback || undefined,
98
+ intentosPermitidos: quiz ? quiz.attempts || "ilimitados" : undefined,
99
+ limiteTiempoMinutos: quiz?.timelimit ? Math.round(quiz.timelimit / 60) : undefined,
100
+ intentos: attempts.map((a) => ({
101
+ numero: a.attempt,
102
+ estado: QUIZ_STATE_LABELS[a.state] ?? a.state,
103
+ comienzo: isoDate(a.timestart),
104
+ fin: isoDate(a.timefinish) || undefined,
105
+ calificacion: attemptGrade(a.sumgrades),
106
+ })),
107
+ };
108
+ }
74
109
  export async function viewChoice(session, courseId, module) {
75
110
  const [choices, options] = await Promise.all([
76
111
  getChoices(session, courseId),
@@ -0,0 +1,18 @@
1
+ import type { AulaSession } from "../client/session.js";
2
+ /**
3
+ * Las calificaciones del usuario en un curso, con las que el docente carga a mano. Las de un módulo
4
+ * traen su `direccion` para abrirlo; las categorías y el total del curso vienen marcados en `tipo`.
5
+ */
6
+ export declare function viewGrades(session: AulaSession, rawAddress: string): Promise<{
7
+ direccion: string;
8
+ calificaciones: {
9
+ nombre: string;
10
+ tipo: string;
11
+ direccion: string | undefined;
12
+ calificacion: string | undefined;
13
+ maximo: number;
14
+ porcentaje: string | undefined;
15
+ oculta: true | undefined;
16
+ devolucion: string | undefined;
17
+ }[];
18
+ }>;
@@ -0,0 +1,31 @@
1
+ import { htmlToText } from "../../shared/html.js";
2
+ import { getCourseGradeItems } from "../client/client.js";
3
+ import { courseAddr, moduleAddr, parseAddress } from "./address.js";
4
+ /** Sin nota, Moodle formatea "-": eso no le dice nada al modelo. */
5
+ const NO_GRADE = "-";
6
+ /** Moodle deja `itemname` vacío en los totales; sin esto, el modelo no sabe de qué es la nota. */
7
+ const UNNAMED_ITEMS = { course: "Total del curso", category: "Total de categoría" };
8
+ /**
9
+ * Las calificaciones del usuario en un curso, con las que el docente carga a mano. Las de un módulo
10
+ * traen su `direccion` para abrirlo; las categorías y el total del curso vienen marcados en `tipo`.
11
+ */
12
+ export async function viewGrades(session, rawAddress) {
13
+ const address = parseAddress(rawAddress);
14
+ if (address.level !== "course") {
15
+ throw new Error(`Pasá la dirección de un curso, como "/curso/691"; "${rawAddress}" no lo es.`);
16
+ }
17
+ const items = await getCourseGradeItems(session, address.courseId);
18
+ return {
19
+ direccion: courseAddr(address.courseId),
20
+ calificaciones: items.map((i) => ({
21
+ nombre: i.itemname || UNNAMED_ITEMS[i.itemtype],
22
+ tipo: i.itemmodule ?? i.itemtype,
23
+ direccion: i.cmid ? moduleAddr(address.courseId, i.cmid) : undefined,
24
+ calificacion: i.gradeformatted && i.gradeformatted !== NO_GRADE ? i.gradeformatted : undefined,
25
+ maximo: i.grademax,
26
+ porcentaje: i.percentageformatted && i.percentageformatted !== NO_GRADE ? i.percentageformatted : undefined,
27
+ oculta: i.gradeishidden || undefined,
28
+ devolucion: htmlToText(i.feedback) || undefined,
29
+ })),
30
+ };
31
+ }
@@ -184,6 +184,45 @@ export declare function open(session: AulaSession, rawAddress: string, page?: nu
184
184
  anotados: number | undefined;
185
185
  }[];
186
186
  };
187
+ } | {
188
+ direccion: string;
189
+ padre: string | null;
190
+ seccion: string;
191
+ modulo: {
192
+ direccion: string;
193
+ id: number;
194
+ nombre: string;
195
+ tipo: string;
196
+ url: string | undefined;
197
+ descripcion: string | undefined;
198
+ restriccion: string | undefined;
199
+ fechas: {
200
+ etiqueta: string;
201
+ fecha: string | null;
202
+ }[] | undefined;
203
+ archivos: {
204
+ nombre: string;
205
+ ruta: string | undefined;
206
+ tamanio: number;
207
+ mimetype: string | undefined;
208
+ modificado: string | null;
209
+ url: string;
210
+ }[] | undefined;
211
+ enlaces: string[] | undefined;
212
+ };
213
+ cuestionario: {
214
+ calificacionFinal: string | undefined;
215
+ devolucion: string | undefined;
216
+ intentosPermitidos: string | number | undefined;
217
+ limiteTiempoMinutos: number | undefined;
218
+ intentos: {
219
+ numero: number;
220
+ estado: string;
221
+ comienzo: string | null;
222
+ fin: string | undefined;
223
+ calificacion: string | undefined;
224
+ }[];
225
+ };
187
226
  } | {
188
227
  direccion: string;
189
228
  padre: string | null;
@@ -1,7 +1,7 @@
1
1
  import { htmlToSnippet, htmlToText } from "../../shared/html.js";
2
2
  import { courseAddr, discussionAddr, formatAddress, moduleAddr, parentOf, parseAddress, } from "./address.js";
3
3
  import { canAddDiscussion, getCourseSections, getDiscussionPosts, getForumDiscussions, getUserCourses, PAGE_SIZE, } from "../client/client.js";
4
- import { viewChoice, viewChoicegroup, viewSubmission } from "./activities.js";
4
+ import { viewChoice, viewChoicegroup, viewQuiz, viewSubmission } from "./activities.js";
5
5
  import { isoDate, toFile } from "./fields.js";
6
6
  /**
7
7
  * Entra a una dirección del aula y devuelve su contenido, el padre y las direcciones de lo que hay
@@ -58,6 +58,8 @@ async function viewModule(session, address, page) {
58
58
  return { ...base, eleccion: await viewChoice(session, address.courseId, module) };
59
59
  case "choicegroup":
60
60
  return { ...base, eleccion: await viewChoicegroup(session, module) };
61
+ case "quiz":
62
+ return { ...base, cuestionario: await viewQuiz(session, address.courseId, module) };
61
63
  default:
62
64
  return base;
63
65
  }
@@ -3,6 +3,7 @@ import { getSiteInfo } from "./client/client.js";
3
3
  import { addCalendarEvent, answerChoice, deleteForumPost, editForumPost, postToForum, sendMessage, } from "./format/actions.js";
4
4
  import { ROOT } from "./format/address.js";
5
5
  import { readAulaFile } from "./format/files.js";
6
+ import { viewGrades } from "./format/grades.js";
6
7
  import { buildStudyPack } from "./format/study-pack.js";
7
8
  import { open } from "./format/navigate.js";
8
9
  import { createUploadLink, submitAssignment } from "./format/submission.js";
@@ -50,7 +51,9 @@ export function registerAulaVirtualTools(server, session, files) {
50
51
  "y módulos, un módulo con sus archivos y fechas, un foro con el índice de sus discusiones, o " +
51
52
  "una discusión con sus mensajes completos.\n\n" +
52
53
  "Según el tipo de módulo trae además: en una tarea (assign), `entrega` con el estado, el " +
53
- "vencimiento, lo que se subió y la devolución con la nota; en una elección (choice) o " +
54
+ "vencimiento, lo que se subió y la devolución con la nota; en un cuestionario (quiz), " +
55
+ "`cuestionario` con la `calificacionFinal` (\"0,00 / 10,00\") y cada intento con su estado, " +
56
+ "comienzo, fin y nota; en una elección (choice) o " +
54
57
  "selección de grupos (choicegroup), `eleccion` con las opciones y sus ids; en un foro, " +
55
58
  "`puedeAbrirHilo`; en una discusión, el `id`, el `autorId` y las `acciones` permitidas de " +
56
59
  "cada publicación. Es lo que piden las tools que escriben en el aula.\n\n" +
@@ -126,6 +129,27 @@ export function registerAulaVirtualTools(server, session, files) {
126
129
  return failure(e);
127
130
  }
128
131
  });
132
+ server.registerTool("aula_grades", {
133
+ description: "Las calificaciones del usuario en un curso, las mismas que ve en \"Calificaciones\" del aula: " +
134
+ "tareas, cuestionarios y también las notas que el docente carga a mano (un TP corregido " +
135
+ "en papel, un parcial), que no aparecen en ningún módulo. Usala para saber cómo va en una " +
136
+ "materia o si aprobó algo.\n\n" +
137
+ "Cada ítem trae `calificacion` (como la formatea el aula, sin nota si todavía no hay), " +
138
+ "`maximo`, `porcentaje` y, si es de un módulo, su `direccion` para abrirlo con aula_open. " +
139
+ "`tipo` dice de dónde sale: el módulo (quiz, assign), \"manual\", \"category\" o " +
140
+ "\"course\" (el total).",
141
+ inputSchema: z.object({
142
+ direccion: z.string().describe('Dirección del curso, de aula_list_courses. Ej: "/curso/691".'),
143
+ }),
144
+ annotations: READ_ONLY,
145
+ }, async ({ direccion }) => {
146
+ try {
147
+ return json(await viewGrades(session, direccion));
148
+ }
149
+ catch (e) {
150
+ return failure(e);
151
+ }
152
+ });
129
153
  // El pack se guarda en el disco: solo en STDIO, donde el servidor corre en la compu del usuario.
130
154
  if (files.local) {
131
155
  server.registerTool("aula_study_pack", {
@@ -9,9 +9,9 @@ export const CATALOG = [
9
9
  id: "sysacad-asp",
10
10
  name: "Sysacad",
11
11
  package: "utn-mcp-sysacad-asp",
12
- version: "0.1.0",
13
- tarball: `${RELEASES}/sysacad-asp-v0.1.0/utn-mcp-sysacad-asp-0.1.0.tgz`,
14
- integrity: "sha512-dFiMVUwQJdLHmrKD6BZnUgn5EU73pzAzSBEEQyCA4P2eXOI6QeTRLnmGW//K1hmFJXl74Ax10VQrpVweY6+1Gw==",
12
+ version: "0.2.0",
13
+ tarball: `${RELEASES}/sysacad-asp-v0.2.0/utn-mcp-sysacad-asp-0.2.0.tgz`,
14
+ integrity: "sha512-p4j2fTpE3ZqSvcDQ3Rr6L32dOGNFut0GzyHBVpTpg8mqXrx4Un6CqkiJZG+eznQy+Risko+pcnPhWm+GIJ8haw==",
15
15
  // FRT anduvo con cuenta real (oct 2026) con la versión anterior, que posteaba directo a `menuAlumno.asp`;
16
16
  // esta lee el formulario y todavía no entró nadie con ella.
17
17
  regionales: Object.fromEntries(SYSACAD_ASP_REGIONALS.map((id) => [id, "candidate"])),
@@ -19,6 +19,16 @@ export interface AutogestionConnector {
19
19
  notasParciales?(): Promise<NotasParcialesMateria[]>;
20
20
  /** Historial de exámenes finales, si el sistema lo publica; no todos los conectores lo tienen. */
21
21
  examenes?(): Promise<ExamenMateria[]>;
22
+ /**
23
+ * La sesión del sistema sin la contraseña (cookies, ids de sesión), para guardarla cifrada y retomarla
24
+ * en otro request. Sin esto, el conector sirve solo en local: el modo HTTP no guarda contraseñas.
25
+ */
26
+ exportSession?(): string;
27
+ /**
28
+ * Retoma una sesión de `exportSession`. Sin la contraseña no puede volver a entrar solo: si la sesión
29
+ * venció, las llamadas rechazan con `LoginRequiredError`.
30
+ */
31
+ restoreSession?(data: string): void;
22
32
  }
23
33
  /** Credenciales de un sistema de autogestión. En Sysacad `username` es el legajo. */
24
34
  export interface AutogestionCredentials {
@@ -1,4 +1,4 @@
1
- import type { CatalogEntry } from "./catalog.js";
1
+ import { type CatalogEntry } from "./catalog.js";
2
2
  /** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
3
3
  export declare const connectorsDir: () => string;
4
4
  export declare const connectorDir: (entry: CatalogEntry) => string;
@@ -7,6 +7,11 @@ export declare function installedManifest(entry: CatalogEntry): Promise<{
7
7
  version?: string;
8
8
  main?: string;
9
9
  } | null>;
10
+ /**
11
+ * Para el modo HTTP, que atiende usuarios de cualquier regional: instala los conectores del catálogo que
12
+ * falten o hayan quedado viejos. Uno que no se puede bajar no frena el servidor: queda sin esas tools.
13
+ */
14
+ export declare function installCatalog(): Promise<void>;
10
15
  /**
11
16
  * Baja el `.tgz` del catálogo, comprueba su hash y lo extrae en su carpeta. Sin `npm install`: los
12
17
  * conectores no tienen dependencias, así que alcanza con desempaquetarlos, y no hace falta npm en la máquina.
@@ -3,7 +3,9 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
4
  import { gunzipSync } from "node:zlib";
5
5
  import { HttpError } from "../../shared/http.js";
6
+ import { logger } from "../../shared/logger.js";
6
7
  import { configDir } from "../../shared/paths.js";
8
+ import { CATALOG } from "./catalog.js";
7
9
  /** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
8
10
  export const connectorsDir = () => join(configDir(), "connectors");
9
11
  export const connectorDir = (entry) => join(connectorsDir(), entry.package);
@@ -16,6 +18,23 @@ export async function installedManifest(entry) {
16
18
  return null;
17
19
  }
18
20
  }
21
+ /**
22
+ * Para el modo HTTP, que atiende usuarios de cualquier regional: instala los conectores del catálogo que
23
+ * falten o hayan quedado viejos. Uno que no se puede bajar no frena el servidor: queda sin esas tools.
24
+ */
25
+ export async function installCatalog() {
26
+ for (const entry of CATALOG) {
27
+ if ((await installedManifest(entry))?.version === entry.version)
28
+ continue;
29
+ try {
30
+ await installConnector(entry);
31
+ logger.info(`Conector de ${entry.name} ${entry.version} instalado.`);
32
+ }
33
+ catch (e) {
34
+ logger.warn(`No se pudo instalar el conector de ${entry.name}: ${e.message}`);
35
+ }
36
+ }
37
+ }
19
38
  /** Bloques del formato tar: encabezado y datos van de a 512 bytes. */
20
39
  const TAR_BLOCK = 512;
21
40
  /**
@@ -1,14 +1,18 @@
1
- import type { AutogestionConnector } from "./connector.js";
1
+ import type { AutogestionConnector, AutogestionSystem } from "./connector.js";
2
2
  /**
3
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.
4
+ * `LocalAutogestionSession` (STDIO, el usuario de la compu, credenciales del keychain) o la hosteada
5
+ * (HTTP, el usuario entra desde un link y la contraseña no se guarda).
6
6
  */
7
7
  export interface AutogestionSession {
8
8
  /**
9
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.
10
+ * Rechaza con un error que dice cómo iniciar sesión si todavía no se puede.
12
11
  */
13
12
  getConnector(): Promise<AutogestionConnector>;
14
13
  }
14
+ /** Los conectores disponibles y cómo armar la sesión de un usuario: lo que recibe `createServer`. */
15
+ export interface AutogestionAccess {
16
+ systems: AutogestionSystem[];
17
+ sessionFor(system: AutogestionSystem, regionalId: string): AutogestionSession;
18
+ }
@@ -0,0 +1,42 @@
1
+ import type { SessionStore } from "../../shared/store/session-store.js";
2
+ import { type AutogestionConnector, type AutogestionCredentials, type AutogestionSystem } from "./connector.js";
3
+ import type { AutogestionAccess } from "./session.js";
4
+ /** Con qué nombre guarda la autogestión su sesión en el store. */
5
+ export declare const AUTOGESTION_PLATFORM = "autogestion";
6
+ /** Un link abierto para que el usuario entre a su autogestión desde el navegador. */
7
+ export interface PendingLink {
8
+ userId: number;
9
+ regionalId: string;
10
+ system: AutogestionSystem;
11
+ }
12
+ /** Falta entrar a la autogestión: el mensaje trae el link para el usuario. */
13
+ export declare class AutogestionLinkRequiredError extends Error {
14
+ constructor(systemName: string, url: string, reason?: string);
15
+ }
16
+ /**
17
+ * La autogestión del modo HTTP, para todos los usuarios del servidor. Igual que el aula: la contraseña se
18
+ * usa una sola vez, en la página del link, y no se guarda; en el store queda cifrada la sesión del
19
+ * sistema (cookies), que el conector exporta. Cuando el sistema la vence, el usuario vuelve a entrar.
20
+ */
21
+ export declare class StoredAutogestion {
22
+ private readonly baseUrl;
23
+ private readonly store;
24
+ /** Los links viven en memoria: si el servidor se reinicia, el agente pide otro. */
25
+ private readonly links;
26
+ /** Solo los conectores que pueden exportar su sesión: los otros necesitarían guardar la contraseña. */
27
+ readonly systems: AutogestionSystem[];
28
+ constructor(baseUrl: string, store: SessionStore, systems: AutogestionSystem[]);
29
+ /** Lo que recibe `createServer` para un usuario. */
30
+ forUser(userId: number): AutogestionAccess;
31
+ /** El conector del usuario con su sesión guardada, o null si no hay una de ese sistema. */
32
+ restore(userId: number, system: AutogestionSystem, regionalId: string): Promise<AutogestionConnector | null>;
33
+ /** Guarda la sesión del conector (cifrada, como todo el store); las cookies pueden cambiar en cada pedido. */
34
+ save(userId: number, connector: AutogestionConnector): Promise<void>;
35
+ /** El store no borra sesiones: una vacía es "no hay". */
36
+ forget(userId: number): Promise<void>;
37
+ /** Un link para que el usuario entre; los que se abrieron antes siguen valiendo hasta que vencen o se usa uno. */
38
+ openLink(userId: number, system: AutogestionSystem, regionalId: string): string;
39
+ link(id: string): PendingLink | undefined;
40
+ /** Entra con lo que el usuario escribió en la página del link y guarda la sesión; la contraseña se descarta. */
41
+ complete(id: string, credentials: AutogestionCredentials): Promise<PendingLink>;
42
+ }
@@ -0,0 +1,126 @@
1
+ import { Expiring } from "../../shared/expiring.js";
2
+ import { connectorKit, InvalidCredentialsError, LoginRequiredError, } from "./connector.js";
3
+ /** Con qué nombre guarda la autogestión su sesión en el store. */
4
+ export const AUTOGESTION_PLATFORM = "autogestion";
5
+ /** Cuánto dura un link para entrar, en milisegundos. */
6
+ const LINK_TTL_MS = 30 * 60 * 1000;
7
+ /** Falta entrar a la autogestión: el mensaje trae el link para el usuario. */
8
+ export class AutogestionLinkRequiredError extends Error {
9
+ constructor(systemName, url, reason) {
10
+ super(`${reason ? `${reason} ` : ""}Falta iniciar sesión en ${systemName}. Pasale al usuario este link para que entre ` +
11
+ `con su legajo y contraseña de ${systemName} (vence en 30 minutos): ${url} . Cuando te avise que entró, ` +
12
+ "reintentá. La contraseña no pasa por el chat: no se la pidas.");
13
+ this.name = "AutogestionLinkRequiredError";
14
+ }
15
+ }
16
+ /**
17
+ * La autogestión del modo HTTP, para todos los usuarios del servidor. Igual que el aula: la contraseña se
18
+ * usa una sola vez, en la página del link, y no se guarda; en el store queda cifrada la sesión del
19
+ * sistema (cookies), que el conector exporta. Cuando el sistema la vence, el usuario vuelve a entrar.
20
+ */
21
+ export class StoredAutogestion {
22
+ baseUrl;
23
+ store;
24
+ /** Los links viven en memoria: si el servidor se reinicia, el agente pide otro. */
25
+ links = new Expiring(LINK_TTL_MS);
26
+ /** Solo los conectores que pueden exportar su sesión: los otros necesitarían guardar la contraseña. */
27
+ systems;
28
+ constructor(baseUrl, store, systems) {
29
+ this.baseUrl = baseUrl;
30
+ this.store = store;
31
+ this.systems = systems.filter((system) => {
32
+ const connector = system.create(system.regionales[0], connectorKit);
33
+ return typeof connector.exportSession === "function" && typeof connector.restoreSession === "function";
34
+ });
35
+ }
36
+ /** Lo que recibe `createServer` para un usuario. */
37
+ forUser(userId) {
38
+ return {
39
+ systems: this.systems,
40
+ sessionFor: (system, regionalId) => new StoredAutogestionSession(this, userId, system, regionalId),
41
+ };
42
+ }
43
+ /** El conector del usuario con su sesión guardada, o null si no hay una de ese sistema. */
44
+ async restore(userId, system, regionalId) {
45
+ const raw = await this.store.loadSession(userId, AUTOGESTION_PLATFORM);
46
+ if (!raw)
47
+ return null;
48
+ const data = JSON.parse(raw);
49
+ if (data.systemId !== system.id)
50
+ return null;
51
+ const connector = system.create(regionalId, connectorKit);
52
+ connector.restoreSession(data.session);
53
+ return connector;
54
+ }
55
+ /** Guarda la sesión del conector (cifrada, como todo el store); las cookies pueden cambiar en cada pedido. */
56
+ async save(userId, connector) {
57
+ const data = { systemId: connector.systemId, session: connector.exportSession() };
58
+ await this.store.saveSession(userId, AUTOGESTION_PLATFORM, JSON.stringify(data));
59
+ }
60
+ /** El store no borra sesiones: una vacía es "no hay". */
61
+ async forget(userId) {
62
+ await this.store.saveSession(userId, AUTOGESTION_PLATFORM, "");
63
+ }
64
+ /** Un link para que el usuario entre; los que se abrieron antes siguen valiendo hasta que vencen o se usa uno. */
65
+ openLink(userId, system, regionalId) {
66
+ return `${this.baseUrl}/autogestion/${this.links.add({ userId, regionalId, system })}`;
67
+ }
68
+ link(id) {
69
+ return this.links.get(id);
70
+ }
71
+ /** Entra con lo que el usuario escribió en la página del link y guarda la sesión; la contraseña se descarta. */
72
+ async complete(id, credentials) {
73
+ const link = this.link(id);
74
+ if (!link)
75
+ throw new Error("El link venció o ya se usó. Pedile al agente uno nuevo.");
76
+ const connector = link.system.create(link.regionalId, connectorKit);
77
+ await connector.login(credentials);
78
+ await this.save(link.userId, connector);
79
+ this.links.take(id);
80
+ return link;
81
+ }
82
+ }
83
+ /** La sesión de un usuario en un request: el conector con la sesión guardada o, si no hay, el link para entrar. */
84
+ class StoredAutogestionSession {
85
+ stored;
86
+ userId;
87
+ system;
88
+ regionalId;
89
+ constructor(stored, userId, system, regionalId) {
90
+ this.stored = stored;
91
+ this.userId = userId;
92
+ this.system = system;
93
+ this.regionalId = regionalId;
94
+ }
95
+ async getConnector() {
96
+ const connector = await this.stored.restore(this.userId, this.system, this.regionalId);
97
+ if (!connector)
98
+ throw this.linkRequired();
99
+ // Después de cada pedido se guarda la sesión, por si el sistema rotó las cookies. Si la venció, se
100
+ // descarta y se pide entrar de nuevo: los mensajes del conector hablan del login por CLI, que acá no existe.
101
+ const guard = async (call) => {
102
+ try {
103
+ const result = await call();
104
+ await this.stored.save(this.userId, connector);
105
+ return result;
106
+ }
107
+ catch (e) {
108
+ if (!(e instanceof InvalidCredentialsError || e instanceof LoginRequiredError))
109
+ throw e;
110
+ await this.stored.forget(this.userId);
111
+ throw this.linkRequired(`La sesión de ${this.system.name} venció.`);
112
+ }
113
+ };
114
+ return {
115
+ systemId: connector.systemId,
116
+ login: (credentials) => connector.login(credentials),
117
+ estadoAcademico: () => guard(() => connector.estadoAcademico()),
118
+ correlatividades: () => guard(() => connector.correlatividades()),
119
+ notasParciales: connector.notasParciales && (() => guard(() => connector.notasParciales())),
120
+ examenes: connector.examenes && (() => guard(() => connector.examenes())),
121
+ };
122
+ }
123
+ linkRequired(reason) {
124
+ return new AutogestionLinkRequiredError(this.system.name, this.stored.openLink(this.userId, this.system, this.regionalId), reason);
125
+ }
126
+ }
@@ -0,0 +1,7 @@
1
+ import { type Express, type RequestHandler } from "express";
2
+ import type { StoredAutogestion } from "./client/stored-session.js";
3
+ /**
4
+ * `/autogestion/:id`: la página del link que el agente le pasa al usuario para entrar a su autogestión
5
+ * en modo HTTP. Sin bearer, como `/subir`: el id del link es el secreto y lo abre un navegador.
6
+ */
7
+ export declare function mountAutogestionLink(app: Express, stored: StoredAutogestion, sameOrigin: RequestHandler): void;
@@ -0,0 +1,62 @@
1
+ import express from "express";
2
+ import { RateLimiter } from "../shared/expiring.js";
3
+ import { logger } from "../shared/logger.js";
4
+ import { escapeHtml, page } from "../shared/pages.js";
5
+ import { InvalidCredentialsError } from "./client/connector.js";
6
+ /** Intentos de login por IP cada 15 minutos (en milisegundos): cada uno es un login contra el sistema de la regional. */
7
+ const MAX_ATTEMPTS = 10;
8
+ const ATTEMPTS_WINDOW_MS = 15 * 60 * 1000;
9
+ /**
10
+ * `/autogestion/:id`: la página del link que el agente le pasa al usuario para entrar a su autogestión
11
+ * en modo HTTP. Sin bearer, como `/subir`: el id del link es el secreto y lo abre un navegador.
12
+ */
13
+ export function mountAutogestionLink(app, stored, sameOrigin) {
14
+ const form = express.urlencoded({ extended: false, limit: "16kb" });
15
+ const limiter = new RateLimiter(MAX_ATTEMPTS, ATTEMPTS_WINDOW_MS);
16
+ app.get("/autogestion/:id", sameOrigin, (req, res) => {
17
+ const link = stored.link(String(req.params.id));
18
+ if (!link)
19
+ return void expired(res);
20
+ res.type("html").send(linkPage(link));
21
+ });
22
+ app.post("/autogestion/:id", sameOrigin, form, async (req, res) => {
23
+ const id = String(req.params.id);
24
+ const link = stored.link(id);
25
+ if (!link)
26
+ return void expired(res);
27
+ const retry = (error) => void res.type("html").send(linkPage(link, error));
28
+ if (!limiter.allow(req.ip ?? "desconocida"))
29
+ return retry("Demasiados intentos. Esperá unos minutos y probá de nuevo.");
30
+ const username = String(req.body?.usuario ?? "").trim();
31
+ const password = String(req.body?.contrasena ?? "");
32
+ if (!username || !password)
33
+ return retry("Completá legajo y contraseña.");
34
+ try {
35
+ await stored.complete(id, { username, password });
36
+ }
37
+ catch (e) {
38
+ if (e instanceof InvalidCredentialsError)
39
+ return retry(`${link.system.name} rechazó el legajo o la contraseña.`);
40
+ logger.warn(`Autogestión: ${e.message}`);
41
+ return retry(e.message);
42
+ }
43
+ res.type("html").send(page("Listo", `<p>Entraste a ${escapeHtml(link.system.name)}. Volvé al chat y pedile al agente que reintente.</p>`));
44
+ });
45
+ }
46
+ function expired(res) {
47
+ res.status(404).type("html").send(page("Link vencido", "<p>Este link venció o ya se usó. Pedile al agente uno nuevo.</p>"));
48
+ }
49
+ function linkPage(link, error) {
50
+ const name = escapeHtml(link.system.name);
51
+ const hint = link.system.usernameHint?.(link.regionalId);
52
+ return page(`Entrar a ${link.system.name}`, `<p>Tu agente necesita entrar a ${name} para leer tu información académica. Usá el legajo y la contraseña ` +
53
+ `de ${name}, que no siempre son los del aula virtual.</p>` +
54
+ (error ? `<p class=error>${escapeHtml(error)}</p>` : "") +
55
+ '<form method="post">' +
56
+ `<label>Legajo${hint ? ` (${escapeHtml(hint)})` : ""}<input name="usuario" autocomplete="username" autocapitalize="none" required></label>` +
57
+ '<label>Contraseña<input name="contrasena" type="password" autocomplete="current-password" required></label>' +
58
+ '<button type="submit">Entrar</button></form>' +
59
+ "<p class=nota>La contraseña se usa una sola vez para entrar y no se guarda: queda guardada, cifrada, " +
60
+ `solo la sesión de ${name}. Cuando ${name} la vence, te va a pedir que entres de nuevo. ` +
61
+ 'El código es abierto: <a href="https://github.com/nialvape/utn-mcp">github.com/nialvape/utn-mcp</a>.</p>', "Autogestión");
62
+ }
@@ -10,15 +10,16 @@ const failure = (e) => ({
10
10
  content: [{ type: "text", text: `Error: ${e.message}` }],
11
11
  isError: true,
12
12
  });
13
+ /** Igual para todas: el login es distinto en local (CLI) y en HTTP (link), así que lo explica el error. */
14
+ const LOGIN_NOTE = " Si todavía no entró a su autogestión, el error dice cómo hacerlo; seguilo. Las credenciales son las del " +
15
+ "sistema de autogestión, que no siempre son las del aula virtual.";
13
16
  export function registerAutogestionTools(server, session) {
14
17
  server.registerTool("autogestion_estado_academico", {
15
18
  description: "Estado académico del usuario según la autogestión de su regional: cada materia OBLIGATORIA " +
16
19
  "del plan con su estado (regular, aprobada, libre), plan, nota y fecha. No es el catálogo " +
17
20
  "completo de la carrera: puede incluir electivas aprobadas y no listar materias sin " +
18
21
  "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
+ "sistema permite saberlas." + LOGIN_NOTE,
22
23
  annotations: { readOnlyHint: true, openWorldHint: false },
23
24
  }, async () => {
24
25
  try {
@@ -31,7 +32,7 @@ export function registerAutogestionTools(server, session) {
31
32
  server.registerTool("autogestion_cursando_ahora", {
32
33
  description: "Materias que el usuario está cursando ahora según la autogestión de su regional, con su " +
33
34
  "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
+ "del estado académico." + LOGIN_NOTE,
35
36
  annotations: { readOnlyHint: true, openWorldHint: false },
36
37
  }, async () => {
37
38
  try {
@@ -45,7 +46,7 @@ export function registerAutogestionTools(server, session) {
45
46
  description: "Historial de exámenes finales del usuario según la autogestión de su regional: fecha, " +
46
47
  "materia, nota o estado del examen cuando todavía no hay nota, plan y código. Puede cubrir " +
47
48
  "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
+ "muestra." + LOGIN_NOTE,
49
50
  annotations: { readOnlyHint: true, openWorldHint: false },
50
51
  }, async () => {
51
52
  try {
@@ -59,8 +60,7 @@ export function registerAutogestionTools(server, session) {
59
60
  description: "Correlatividades de la carrera del usuario según la autogestión de su regional, separadas " +
60
61
  "para cursar y para rendir cuando el sistema las distingue: para cada materia, qué exige " +
61
62
  "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.",
63
+ "correlatividades: usala en lugar de suponer datos de un plan fijo." + LOGIN_NOTE,
64
64
  annotations: { readOnlyHint: true, openWorldHint: false },
65
65
  }, async () => {
66
66
  try {
package/build/http.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type AuthInfo, type McpServerFactory } from "@modelcontextprotocol/server";
2
+ import type { StoredAutogestion } from "./autogestion/client/stored-session.js";
2
3
  import type { AccessPolicy } from "./oauth/access.js";
3
4
  import type { RedirectAllowlist } from "./oauth/redirects.js";
4
5
  import type { SessionStore } from "./shared/store/session-store.js";
@@ -18,5 +19,8 @@ export interface HttpOptions {
18
19
  /** Con él se habilita la API de /admin (ver `mountAdmin`). */
19
20
  adminSecret?: string;
20
21
  }
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;
22
+ /**
23
+ * El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión.
24
+ * `autogestion` sirve la página donde cada usuario entra a la autogestión de su regional.
25
+ */
26
+ export declare function startHttpServer(factory: McpServerFactory, options: HttpOptions, store: SessionStore, inbox: UploadInbox, autogestion: StoredAutogestion): void;
package/build/http.js CHANGED
@@ -2,6 +2,7 @@ import express from "express";
2
2
  import { Readable } from "node:stream";
3
3
  import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, OAuthErrorCode, verifyBearerToken, } from "@modelcontextprotocol/server";
4
4
  import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node";
5
+ import { mountAutogestionLink } from "./autogestion/link-page.js";
5
6
  import { mountAdmin } from "./oauth/admin.js";
6
7
  import { cors, mountOAuth } from "./oauth/server.js";
7
8
  import { logger } from "./shared/logger.js";
@@ -41,8 +42,11 @@ function storeVerifier(store) {
41
42
  };
42
43
  }
43
44
  const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
44
- /** El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión. */
45
- export function startHttpServer(factory, options, store, inbox) {
45
+ /**
46
+ * El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión.
47
+ * `autogestion` sirve la página donde cada usuario entra a la autogestión de su regional.
48
+ */
49
+ export function startHttpServer(factory, options, store, inbox, autogestion) {
46
50
  const { host, port, publicUrl, access, redirects, adminSecret } = options;
47
51
  const handler = createMcpHandler(factory, { onerror: (e) => logger.error(e.message) });
48
52
  const verifier = storeVerifier(store);
@@ -99,6 +103,7 @@ export function startHttpServer(factory, options, store, inbox) {
99
103
  res.type("html").send(page(upload.title, uploadForm(upload.limits)));
100
104
  });
101
105
  app.post("/subir/:id", sameOrigin, (req, res) => receiveUpload(inbox, req, res));
106
+ mountAutogestionLink(app, autogestion, sameOrigin);
102
107
  app.listen(port, host, () => {
103
108
  logger.info(`Servidor MCP escuchando en http://${host}:${port}/mcp`);
104
109
  if (publicUrl)
package/build/index.js CHANGED
@@ -6,6 +6,10 @@ 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 { installCatalog } from "./autogestion/client/install.js";
10
+ import { loadInstalledSystems } from "./autogestion/client/installed-systems.js";
11
+ import { LocalAutogestionSession } from "./autogestion/client/local-session.js";
12
+ import { StoredAutogestion } from "./autogestion/client/stored-session.js";
9
13
  import { offerAutogestionLogin, runAutogestionLogin } from "./autogestion/login.js";
10
14
  import { startHttpServer, userIdOf } from "./http.js";
11
15
  import { accessPolicyFromEnv } from "./oauth/access.js";
@@ -45,7 +49,7 @@ async function main() {
45
49
  }
46
50
  else {
47
51
  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.
52
+ // En HTTP cada usuario entra a su autogestión desde el link que le pasa el agente.
49
53
  if (!values.http)
50
54
  await offerAutogestionLogin((await new LocalSession().getRegional()).id);
51
55
  }
@@ -61,10 +65,13 @@ async function main() {
61
65
  const loginUrl = process.env.UTN_MCP_LOGIN_URL || `${baseUrl}/login`;
62
66
  const store = await SqliteStore.open();
63
67
  const inbox = new UploadInbox(baseUrl);
68
+ // Atiende a usuarios de cualquier regional: se instalan todos los conectores del catálogo.
69
+ await installCatalog();
70
+ const autogestion = new StoredAutogestion(baseUrl, store, await loadSystems());
64
71
  const factory = (ctx) => {
65
72
  const userId = userIdOf(ctx.authInfo);
66
73
  const aula = new StoredAulaSession(store, userId, loginUrl);
67
- return createServer(aula, { local: false, inbox, owner: String(userId) });
74
+ return createServer(aula, { local: false, inbox, owner: String(userId) }, autogestion.forUser(userId));
68
75
  };
69
76
  const options = {
70
77
  host: values.host,
@@ -74,15 +81,29 @@ async function main() {
74
81
  redirects: redirectAllowlistFromEnv(),
75
82
  adminSecret: process.env.UTN_MCP_ADMIN_SECRET || undefined,
76
83
  };
77
- startHttpServer(factory, options, store, inbox);
84
+ startHttpServer(factory, options, store, inbox, autogestion);
78
85
  }
79
86
  else {
80
87
  // STDIO: un solo usuario, el de la compu, con la misma sesión para todas las conexiones.
81
88
  const aula = new LocalSession();
82
- serveStdio(() => createServer(aula, { local: true }), { onerror: (e) => logger.error(e.message) });
89
+ const autogestion = {
90
+ systems: await loadSystems(),
91
+ sessionFor: (system, regionalId) => new LocalAutogestionSession(system, regionalId),
92
+ };
93
+ serveStdio(() => createServer(aula, { local: true }, autogestion), { onerror: (e) => logger.error(e.message) });
83
94
  logger.info("Servidor MCP iniciado en STDIO");
84
95
  }
85
96
  }
97
+ /** Los conectores de autogestión instalados. Uno roto no puede dejar sin aula virtual: si falla, se sigue sin ellos. */
98
+ async function loadSystems() {
99
+ try {
100
+ return await loadInstalledSystems();
101
+ }
102
+ catch (e) {
103
+ logger.warn(e.message);
104
+ return [];
105
+ }
106
+ }
86
107
  main().catch((e) => {
87
108
  logger.error(e.message);
88
109
  process.exit(1);
@@ -5,7 +5,7 @@ import { availableRegionals } from "../aula-virtual/client/regionals.js";
5
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 { Expiring, RateLimiter } from "./expiring.js";
8
+ import { Expiring, RateLimiter } from "../shared/expiring.js";
9
9
  import { PendingLogin } from "./pending-login.js";
10
10
  /** Intentos de `/admin/verificar` por cuenta del aula cada 15 minutos: que no sirva para probar contraseñas. */
11
11
  const VERIFY_ATTEMPTS = 5;
@@ -6,7 +6,7 @@ import { availableRegionals, defaultRegional } from "../aula-virtual/client/regi
6
6
  import { InvalidCredentialsError, MfaError } from "../aula-virtual/client/sso-login.js";
7
7
  import { logger } from "../shared/logger.js";
8
8
  import { escapeHtml, page } from "../shared/pages.js";
9
- import { Expiring, RateLimiter } from "./expiring.js";
9
+ import { Expiring, RateLimiter } from "../shared/expiring.js";
10
10
  import { PendingLogin } from "./pending-login.js";
11
11
  import { describeRedirect, redirectTrust } from "./redirects.js";
12
12
  /** Duración del access token, en segundos: una baja de acceso se aplica a más tardar en este tiempo. */
package/build/server.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  import type { AulaSession } from "./aula-virtual/client/session.js";
3
+ import type { AutogestionAccess } from "./autogestion/client/session.js";
3
4
  import type { FileSource } from "./shared/uploads.js";
4
5
  /**
5
6
  * Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
6
7
  * `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
+ * `autogestion`: los conectores cargados y la sesión del usuario; sus tools aparecen solo si uno cubre su regional.
9
+ * Es async porque resuelve la regional del usuario antes de registrar la autogestión.
8
10
  */
9
- export declare function createServer(aula: AulaSession, files: FileSource): Promise<McpServer>;
11
+ export declare function createServer(aula: AulaSession, files: FileSource, autogestion?: AutogestionAccess): Promise<McpServer>;
package/build/server.js CHANGED
@@ -1,29 +1,27 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
3
- import { loadInstalledSystems } from "./autogestion/client/installed-systems.js";
4
- import { LocalAutogestionSession } from "./autogestion/client/local-session.js";
5
3
  import { systemForRegional } from "./autogestion/client/connector.js";
6
4
  import { registerAutogestionTools } from "./autogestion/tools.js";
7
5
  import { logger } from "./shared/logger.js";
8
6
  /**
9
7
  * Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
10
8
  * `files`: de dónde salen los archivos que el usuario sube a una plataforma.
11
- * Es async porque, en local, resuelve la regional y los conectores instalados antes de registrar la autogestión.
9
+ * `autogestion`: los conectores cargados y la sesión del usuario; sus tools aparecen solo si uno cubre su regional.
10
+ * Es async porque resuelve la regional del usuario antes de registrar la autogestión.
12
11
  */
13
- export async function createServer(aula, files) {
12
+ export async function createServer(aula, files, autogestion) {
14
13
  const server = new McpServer({
15
14
  name: "UTN-MCP",
16
- version: "0.9.0",
15
+ version: "0.9.2",
17
16
  });
18
17
  registerAulaVirtualTools(server, aula, files);
19
- if (files.local) {
18
+ if (autogestion?.systems.length) {
20
19
  // Un conector roto no puede dejar sin aula virtual: si falla, seguimos sin las tools de autogestión.
21
20
  try {
22
21
  const regional = await aula.getRegional();
23
- const systems = await loadInstalledSystems();
24
- const system = systemForRegional(regional.id, systems);
22
+ const system = systemForRegional(regional.id, autogestion.systems);
25
23
  if (system)
26
- registerAutogestionTools(server, new LocalAutogestionSession(system, regional.id));
24
+ registerAutogestionTools(server, autogestion.sessionFor(system, regional.id));
27
25
  }
28
26
  catch (e) {
29
27
  logger.warn(e.message);
@@ -1,7 +1,8 @@
1
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.
2
+ * Valores que viven en memoria hasta que se usan o vencen, con un id aleatorio que sirve de secreto: los
3
+ * pedidos de `/authorize` mientras el usuario completa el login, los codes del OAuth hasta que el cliente
4
+ * los canjea o los links para entrar a la autogestión. Si el servidor se reinicia, se pierden y hay que
5
+ * volver a empezar, que es lo que pasaría igual con uno vencido.
5
6
  */
6
7
  export declare class Expiring<T> {
7
8
  private readonly ttlMs;
@@ -1,8 +1,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  /**
3
- * Valores que viven en memoria hasta que se usan o vencen: los pedidos de `/authorize` mientras el
4
- * usuario completa el login y los codes hasta que el cliente los canjea. Si el servidor se reinicia,
5
- * se pierden y el cliente vuelve a empezar el OAuth, que es lo que haría igual con un code vencido.
3
+ * Valores que viven en memoria hasta que se usan o vencen, con un id aleatorio que sirve de secreto: los
4
+ * pedidos de `/authorize` mientras el usuario completa el login, los codes del OAuth hasta que el cliente
5
+ * los canjea o los links para entrar a la autogestión. Si el servidor se reinicia, se pierden y hay que
6
+ * volver a empezar, que es lo que pasaría igual con uno vencido.
6
7
  */
7
8
  export class Expiring {
8
9
  ttlMs;
package/package.json CHANGED
@@ -1,66 +1,66 @@
1
- {
2
- "name": "utn-mcp",
3
- "version": "0.9.0",
4
- "description": "Este es el Model Context Protocol server NO OFICIAL de la UTN para buscar informacion o archivos en el aula virtual.",
5
- "main": "build/index.js",
6
- "bin": {
7
- "utn-mcp": "build/index.js"
8
- },
9
- "files": [
10
- "build",
11
- "vendor"
12
- ],
13
- "scripts": {
14
- "prebuild": "node -e \"require('fs').rmSync('build',{recursive:true,force:true})\"",
15
- "build": "tsc",
16
- "prepublishOnly": "npm run build",
17
- "prepare": "node scripts/vendor-pdfjs.mjs",
18
- "start": "node build/index.js",
19
- "dev": "tsx src/index.ts",
20
- "test": "echo \"Error: no test specified\" && exit 1",
21
- "login": "tsx src/index.ts login"
22
- },
23
- "repository": {
24
- "type": "git",
25
- "url": "git+https://github.com/nialvape/utn-mcp.git"
26
- },
27
- "keywords": [
28
- "mcp",
29
- "model-context-protocol",
30
- "utn",
31
- "aula-virtual"
32
- ],
33
- "author": "Joaquín Peñalva (https://github.com/nialvape)",
34
- "license": "ISC",
35
- "type": "module",
36
- "bugs": {
37
- "url": "https://github.com/nialvape/utn-mcp/issues"
38
- },
39
- "homepage": "https://utnmcp.com.ar",
40
- "dependencies": {
41
- "@modelcontextprotocol/node": "^2.1.1",
42
- "@modelcontextprotocol/server": "^2.3.0",
43
- "@napi-rs/keyring": "^2.1.0",
44
- "express": "^5.2.1",
45
- "pdf-lib": "^1.17.1",
46
- "tough-cookie": "^6.0.2",
47
- "zod": "^4.6.5"
48
- },
49
- "devDependencies": {
50
- "@types/express": "^5.0.6",
51
- "@types/node": "^26.6.4",
52
- "pdfjs-dist": "^6.4.299",
53
- "tsx": "^4.23.15",
54
- "typescript": "^7.0.2"
55
- },
56
- "engines": {
57
- "node": ">=20"
58
- },
59
- "exports": {
60
- ".": "./build/index.js",
61
- "./autogestion": {
62
- "types": "./build/autogestion/client/connector.d.ts",
63
- "default": "./build/autogestion/client/connector.js"
64
- }
65
- }
66
- }
1
+ {
2
+ "name": "utn-mcp",
3
+ "version": "0.9.2",
4
+ "description": "Este es el Model Context Protocol server NO OFICIAL de la UTN para buscar informacion o archivos en el aula virtual.",
5
+ "main": "build/index.js",
6
+ "bin": {
7
+ "utn-mcp": "build/index.js"
8
+ },
9
+ "files": [
10
+ "build",
11
+ "vendor"
12
+ ],
13
+ "scripts": {
14
+ "prebuild": "node -e \"require('fs').rmSync('build',{recursive:true,force:true})\"",
15
+ "build": "tsc",
16
+ "prepublishOnly": "npm run build",
17
+ "prepare": "node scripts/vendor-pdfjs.mjs",
18
+ "start": "node build/index.js",
19
+ "dev": "tsx src/index.ts",
20
+ "test": "echo \"Error: no test specified\" && exit 1",
21
+ "login": "tsx src/index.ts login"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/nialvape/utn-mcp.git"
26
+ },
27
+ "keywords": [
28
+ "mcp",
29
+ "model-context-protocol",
30
+ "utn",
31
+ "aula-virtual"
32
+ ],
33
+ "author": "Joaquín Peñalva (https://github.com/nialvape)",
34
+ "license": "ISC",
35
+ "type": "module",
36
+ "bugs": {
37
+ "url": "https://github.com/nialvape/utn-mcp/issues"
38
+ },
39
+ "homepage": "https://utnmcp.com.ar",
40
+ "dependencies": {
41
+ "@modelcontextprotocol/node": "^2.1.1",
42
+ "@modelcontextprotocol/server": "^2.3.0",
43
+ "@napi-rs/keyring": "^2.1.0",
44
+ "express": "^5.2.1",
45
+ "pdf-lib": "^1.17.1",
46
+ "tough-cookie": "^6.0.2",
47
+ "zod": "^4.6.5"
48
+ },
49
+ "devDependencies": {
50
+ "@types/express": "^5.0.6",
51
+ "@types/node": "^26.6.4",
52
+ "pdfjs-dist": "^6.4.299",
53
+ "tsx": "^4.23.15",
54
+ "typescript": "^7.0.2"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ },
59
+ "exports": {
60
+ ".": "./build/index.js",
61
+ "./autogestion": {
62
+ "types": "./build/autogestion/client/connector.d.ts",
63
+ "default": "./build/autogestion/client/connector.js"
64
+ }
65
+ }
66
+ }