utn-mcp 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/build/aula-virtual/client/client.js +21 -0
- package/build/aula-virtual/client/link.js +3 -2
- package/build/aula-virtual/client/local-session.js +3 -3
- package/build/aula-virtual/client/moodle-api.js +28 -4
- package/build/aula-virtual/client/sso-login.js +144 -17
- package/build/aula-virtual/client/stored-session.js +4 -2
- package/build/aula-virtual/format/address.js +11 -0
- package/build/aula-virtual/format/files.js +2 -2
- package/build/aula-virtual/format/study-pack.js +199 -0
- package/build/aula-virtual/login.js +14 -2
- package/build/aula-virtual/tools.js +40 -0
- package/build/oauth/admin.js +51 -4
- package/build/oauth/pending-login.js +33 -0
- package/build/oauth/server.js +86 -41
- package/build/server.js +1 -1
- package/build/shared/documents/merge.js +197 -0
- package/build/shared/pages.js +2 -1
- package/build/shared/paths.js +4 -0
- package/package.json +2 -1
|
@@ -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 { buildStudyPack } from "./format/study-pack.js";
|
|
6
7
|
import { open } from "./format/navigate.js";
|
|
7
8
|
import { createUploadLink, submitAssignment } from "./format/submission.js";
|
|
8
9
|
// JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
|
|
@@ -125,6 +126,45 @@ export function registerAulaVirtualTools(server, session, files) {
|
|
|
125
126
|
return failure(e);
|
|
126
127
|
}
|
|
127
128
|
});
|
|
129
|
+
// El pack se guarda en el disco: solo en STDIO, donde el servidor corre en la compu del usuario.
|
|
130
|
+
if (files.local) {
|
|
131
|
+
server.registerTool("aula_study_pack", {
|
|
132
|
+
description: "Une PDF del aula en un solo compilado de estudio con índice clickeable (cada " +
|
|
133
|
+
"entrada abre la página donde empieza ese material, y cada página tiene arriba " +
|
|
134
|
+
"un enlace para volver), guardado en la compu del usuario. Une ÚNICAMENTE PDF; " +
|
|
135
|
+
"si una URL pedida no es PDF, queda en `omitidos` con el motivo.\n\n" +
|
|
136
|
+
"Conviene llamar antes a aula_open del curso: devuelve cada módulo con sus " +
|
|
137
|
+
"archivos (nombre, url, sección y tipo) y permite elegir los apuntes sin bajar " +
|
|
138
|
+
"nada. Es un compilado íntegro, no un resumen.\n\n" +
|
|
139
|
+
"Hay topes por memoria: 150 archivos, 300 MB de descarga total y 30 MB el PDF " +
|
|
140
|
+
"final (estimado con los tamaños de entrada, para no bajar archivos de más). Lo " +
|
|
141
|
+
"que no entra queda en `omitidos` con el motivo: se pide el resto en otro pack. " +
|
|
142
|
+
"La respuesta trae la ruta en `archivo` y `materiales` con sección, módulo, " +
|
|
143
|
+
"páginas y página de inicio de cada uno.\n\n" +
|
|
144
|
+
"La `direccion` del curso sale de aula_list_courses. Para leer o pasarle al " +
|
|
145
|
+
"usuario archivos que no son PDF, usá aula_open y aula_read_file.",
|
|
146
|
+
inputSchema: z.object({
|
|
147
|
+
direccion: z
|
|
148
|
+
.string()
|
|
149
|
+
.describe('Dirección del curso, de aula_list_courses. Ej: "/curso/691".'),
|
|
150
|
+
archivos: z
|
|
151
|
+
.array(z.string())
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("URL de los PDF a unir, tal como las devuelve aula_open del curso (conviene " +
|
|
154
|
+
"llamarlo antes y elegir: cada archivo trae nombre, sección y tipo). El pack " +
|
|
155
|
+
"sigue el orden del aula, no el de esta lista. Sin `archivos`, une todos los " +
|
|
156
|
+
"PDF del curso."),
|
|
157
|
+
}),
|
|
158
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
159
|
+
}, async ({ direccion, archivos }) => {
|
|
160
|
+
try {
|
|
161
|
+
return json(await buildStudyPack(session, direccion, archivos));
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
return failure(e);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
128
168
|
// --- Escritura: lo que sigue cambia algo en el aula, y casi todo queda a la vista de otras personas. ---
|
|
129
169
|
const CONFIRM_FIRST = "\n\nAntes de llamarla, mostrale al usuario exactamente lo que se va a hacer y esperá que confirme.";
|
|
130
170
|
server.registerTool("aula_forum_post", {
|
package/build/oauth/admin.js
CHANGED
|
@@ -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 } 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
|
|
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,11 +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
|
}
|
|
143
|
+
if (e instanceof MfaError)
|
|
144
|
+
return void res.status(422).json({ error: "mfa", motivo: e.message });
|
|
111
145
|
logger.warn(`Verificación de ${username} (${regional.id}): ${e.message}`);
|
|
112
146
|
res.status(502).json({ error: "aula", motivo: `No pudimos entrar al aula de ${regional.name}.` });
|
|
113
147
|
}
|
|
114
|
-
}
|
|
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 };
|
|
115
162
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Un login que queda esperando el segundo factor entre un request y otro: corre en segundo plano y,
|
|
3
|
+
* cuando le pregunta algo al usuario, `next` lo devuelve para mostrárselo en una página; `answer` le
|
|
4
|
+
* pasa lo que el usuario contestó en el request siguiente.
|
|
5
|
+
*/
|
|
6
|
+
export class PendingLogin {
|
|
7
|
+
run;
|
|
8
|
+
waiting = null;
|
|
9
|
+
listeners = [];
|
|
10
|
+
constructor(start) {
|
|
11
|
+
this.run = start((prompt) => new Promise((resolve) => {
|
|
12
|
+
this.waiting = { prompt, resolve };
|
|
13
|
+
for (const listener of this.listeners.splice(0))
|
|
14
|
+
listener(prompt);
|
|
15
|
+
}));
|
|
16
|
+
this.run.catch(() => { });
|
|
17
|
+
}
|
|
18
|
+
/** Espera a que el login termine o le pregunte algo al usuario. */
|
|
19
|
+
next() {
|
|
20
|
+
if (this.waiting)
|
|
21
|
+
return Promise.resolve({ prompt: this.waiting.prompt });
|
|
22
|
+
return Promise.race([
|
|
23
|
+
this.run.then((done) => ({ done })),
|
|
24
|
+
new Promise((resolve) => this.listeners.push((prompt) => resolve({ prompt }))),
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
/** Contesta lo que está preguntando; si no pregunta nada (un formulario mandado dos veces), no hace nada. */
|
|
28
|
+
answer(value) {
|
|
29
|
+
const waiting = this.waiting;
|
|
30
|
+
this.waiting = null;
|
|
31
|
+
waiting?.resolve(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
package/build/oauth/server.js
CHANGED
|
@@ -3,10 +3,11 @@ import express from "express";
|
|
|
3
3
|
import { buildOAuthProtectedResourceMetadata, checkResourceAllowed, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
|
|
4
4
|
import { linkAulaAccount } from "../aula-virtual/client/link.js";
|
|
5
5
|
import { availableRegionals, defaultRegional } from "../aula-virtual/client/regionals.js";
|
|
6
|
-
import { InvalidCredentialsError } from "../aula-virtual/client/sso-login.js";
|
|
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
9
|
import { Expiring, RateLimiter } from "./expiring.js";
|
|
10
|
+
import { PendingLogin } from "./pending-login.js";
|
|
10
11
|
import { describeRedirect, redirectTrust } from "./redirects.js";
|
|
11
12
|
/** Duración del access token, en segundos: una baja de acceso se aplica a más tardar en este tiempo. */
|
|
12
13
|
const ACCESS_TTL = 60 * 60;
|
|
@@ -49,6 +50,7 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }
|
|
|
49
50
|
});
|
|
50
51
|
const pending = new Expiring(PENDING_TTL_MS);
|
|
51
52
|
const codes = new Expiring(CODE_TTL_MS);
|
|
53
|
+
const secondFactors = new Expiring(PENDING_TTL_MS);
|
|
52
54
|
const limiter = new RateLimiter(LOGIN_ATTEMPTS, LOGIN_WINDOW_MS);
|
|
53
55
|
const registerLimiter = new RateLimiter(REGISTER_ATTEMPTS, REGISTER_WINDOW_MS);
|
|
54
56
|
const form = express.urlencoded({ extended: false, limit: "16kb" });
|
|
@@ -130,7 +132,7 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }
|
|
|
130
132
|
if (!request)
|
|
131
133
|
return void errorPage(res, 400, "Pedido vencido", "Pasó demasiado tiempo. Volvé a conectar desde tu app.");
|
|
132
134
|
const list = await regionals();
|
|
133
|
-
const retry = (error) => res.type("html").send(loginPage({
|
|
135
|
+
const retry = (res, error) => res.type("html").send(loginPage({
|
|
134
136
|
action: "/authorize",
|
|
135
137
|
regionals: list,
|
|
136
138
|
pendingId: id,
|
|
@@ -141,23 +143,24 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }
|
|
|
141
143
|
error,
|
|
142
144
|
}));
|
|
143
145
|
if (request.unknownRedirect && req.body?.acepto !== "si") {
|
|
144
|
-
return void retry("Para conectar una app que no es un cliente conocido tenés que aceptar el aviso.");
|
|
146
|
+
return void retry(res, "Para conectar una app que no es un cliente conocido tenés que aceptar el aviso.");
|
|
145
147
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
148
|
+
await login(req, res, list, retry, async (res, account) => {
|
|
149
|
+
const decision = await access({ regional: account.regional.id, username: account.username, email: account.email });
|
|
150
|
+
if (!decision.allowed)
|
|
151
|
+
return void deniedPage(res, decision.reason, decision.url);
|
|
152
|
+
if (!pending.take(id)) {
|
|
153
|
+
return void errorPage(res, 400, "Pedido vencido", "Pasó demasiado tiempo. Volvé a conectar desde tu app.");
|
|
154
|
+
}
|
|
155
|
+
const code = codes.add({
|
|
156
|
+
userId: account.userId,
|
|
157
|
+
clientId: request.clientId,
|
|
158
|
+
redirectUri: request.redirectUri,
|
|
159
|
+
codeChallenge: request.codeChallenge,
|
|
160
|
+
});
|
|
161
|
+
logger.info(`OAuth: ${account.username} (${account.regional.id}) autorizó a ${request.clientName ?? request.clientId}.`);
|
|
162
|
+
redirectWith(res, request.redirectUri, { code, state: request.state, iss: issuer });
|
|
158
163
|
});
|
|
159
|
-
logger.info(`OAuth: ${account.username} (${account.regional.id}) autorizó a ${request.clientName ?? request.clientId}.`);
|
|
160
|
-
redirectWith(res, request.redirectUri, { code, state: request.state, iss: issuer });
|
|
161
164
|
});
|
|
162
165
|
app.post("/token", cors, form, express.json({ limit: "16kb" }), async (req, res) => {
|
|
163
166
|
const body = (req.body ?? {});
|
|
@@ -213,54 +216,74 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }
|
|
|
213
216
|
app.get("/login", async (_req, res) => void res.type("html").send(loginPage({ action: "/login", regionals: await regionals() })));
|
|
214
217
|
app.post("/login", sameOrigin, form, async (req, res) => {
|
|
215
218
|
const list = await regionals();
|
|
216
|
-
const
|
|
219
|
+
const retry = (res, error) => res.type("html").send(loginPage({
|
|
217
220
|
action: "/login",
|
|
218
221
|
regionals: list,
|
|
219
222
|
regional: req.body?.regional,
|
|
220
223
|
error,
|
|
221
|
-
}))
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
}));
|
|
225
|
+
await login(req, res, list, retry, async (res, account) => {
|
|
226
|
+
logger.info(`OAuth: ${account.username} (${account.regional.id}) renovó su sesión del aula.`);
|
|
227
|
+
res.type("html").send(page("Listo", "<p>Ya renovamos tu sesión del aula. Volvé al chat y pedile al agente que reintente.</p>"));
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
app.post("/segundo-factor", sameOrigin, form, async (req, res) => {
|
|
231
|
+
const id = String(req.body?.login ?? "");
|
|
232
|
+
const utnLogin = secondFactors.get(id);
|
|
233
|
+
if (!utnLogin)
|
|
234
|
+
return void errorPage(res, 400, "Login vencido", "Pasó demasiado tiempo. Volvé a iniciar sesión.");
|
|
235
|
+
utnLogin.flow.answer(String(req.body?.respuesta ?? ""));
|
|
236
|
+
await advance(res, utnLogin, id);
|
|
226
237
|
});
|
|
227
238
|
/** Entra al aula con lo que mandó el formulario; si falla, vuelve a mostrarlo con el error. */
|
|
228
|
-
async function login(req, list, retry) {
|
|
239
|
+
async function login(req, res, list, retry, finish) {
|
|
229
240
|
if (!limiter.allow(req.ip ?? "desconocida")) {
|
|
230
|
-
retry("Demasiados intentos. Esperá unos minutos y probá de nuevo.");
|
|
231
|
-
return null;
|
|
241
|
+
return retry(res, "Demasiados intentos. Esperá unos minutos y probá de nuevo.");
|
|
232
242
|
}
|
|
233
243
|
const username = String(req.body?.usuario ?? "").trim();
|
|
234
244
|
const password = String(req.body?.contrasena ?? "");
|
|
235
|
-
if (!username || !password)
|
|
236
|
-
retry("Completá usuario y contraseña.");
|
|
237
|
-
return null;
|
|
238
|
-
}
|
|
245
|
+
if (!username || !password)
|
|
246
|
+
return retry(res, "Completá usuario y contraseña.");
|
|
239
247
|
const regional = list.find((r) => r.id === String(req.body?.regional ?? ""));
|
|
240
|
-
if (!regional)
|
|
241
|
-
retry("Elegí tu regional de la lista.");
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
248
|
+
if (!regional)
|
|
249
|
+
return retry(res, "Elegí tu regional de la lista.");
|
|
244
250
|
if (regional.support === "unsupported") {
|
|
245
|
-
retry(`Todavía no hay soporte para ${regional.name}: su aula todavía no funciona con UTN-MCP.`);
|
|
246
|
-
return null;
|
|
251
|
+
return retry(res, `Todavía no hay soporte para ${regional.name}: su aula todavía no funciona con UTN-MCP.`);
|
|
247
252
|
}
|
|
253
|
+
const flow = new PendingLogin((secondFactor) => linkAulaAccount(store, regional, { username, password }, secondFactor));
|
|
254
|
+
await advance(res, { flow, regional, username, retry, finish });
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Sigue el login hasta que termina o pide el segundo factor: entonces muestra la página para
|
|
258
|
+
* contestarlo y lo guarda hasta la respuesta. `id` es el de un login que ya estaba esperando.
|
|
259
|
+
*/
|
|
260
|
+
async function advance(res, utnLogin, id) {
|
|
261
|
+
const { flow, regional, username, retry, finish } = utnLogin;
|
|
248
262
|
try {
|
|
249
|
-
|
|
263
|
+
const step = await flow.next();
|
|
264
|
+
if ("prompt" in step) {
|
|
265
|
+
return void res.type("html").send(secondFactorPage(id ?? secondFactors.add(utnLogin), step.prompt));
|
|
266
|
+
}
|
|
267
|
+
if (id)
|
|
268
|
+
secondFactors.take(id);
|
|
269
|
+
await finish(res, step.done);
|
|
250
270
|
}
|
|
251
271
|
catch (e) {
|
|
272
|
+
if (id)
|
|
273
|
+
secondFactors.take(id);
|
|
252
274
|
if (e instanceof InvalidCredentialsError)
|
|
253
|
-
retry("La UTN rechazó el usuario o la contraseña.");
|
|
275
|
+
retry(res, "La UTN rechazó el usuario o la contraseña.");
|
|
276
|
+
else if (e instanceof MfaError)
|
|
277
|
+
retry(res, e.message);
|
|
254
278
|
else if (regional.support === "candidate") {
|
|
255
279
|
// Nadie la había usado acá: lo más probable es que todavía no ande, no que esté caída.
|
|
256
280
|
logger.warn(`Regional ${regional.id} sin verificar, falló el login de ${username}: ${e.message}`);
|
|
257
|
-
retry(`No pudimos usar el aula de ${regional.name}: puede que todavía no ande con UTN-MCP.`);
|
|
281
|
+
retry(res, `No pudimos usar el aula de ${regional.name}: puede que todavía no ande con UTN-MCP.`);
|
|
258
282
|
}
|
|
259
283
|
else {
|
|
260
284
|
logger.error(`Login de ${username} (${regional.id}): ${e.message}`);
|
|
261
|
-
retry("No pudimos entrar al aula. Puede que esté caída: probá de nuevo en unos minutos.");
|
|
285
|
+
retry(res, "No pudimos entrar al aula. Puede que esté caída: probá de nuevo en unos minutos.");
|
|
262
286
|
}
|
|
263
|
-
return null;
|
|
264
287
|
}
|
|
265
288
|
}
|
|
266
289
|
return getOAuthProtectedResourceMetadataUrl(new URL(resource));
|
|
@@ -299,6 +322,28 @@ function deniedPage(res, reason, url) {
|
|
|
299
322
|
const link = url ? `<p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>` : "";
|
|
300
323
|
res.status(403).type("html").send(page("Sin acceso", `<p>${escapeHtml(reason)}</p>${link}`));
|
|
301
324
|
}
|
|
325
|
+
/** La página del segundo factor de Microsoft: elegir el método, tipear el código o avisar que ya se aprobó. */
|
|
326
|
+
function secondFactorPage(id, prompt) {
|
|
327
|
+
const hidden = `<input type="hidden" name="login" value="${escapeHtml(id)}">`;
|
|
328
|
+
const formWith = (fields, button) => `<form method="post" action="/segundo-factor">${hidden}${fields}<button type="submit">${button}</button></form>`;
|
|
329
|
+
if (prompt.step === "choose") {
|
|
330
|
+
const options = prompt.methods
|
|
331
|
+
.map((m, i) => `<label class=acepto><input type="radio" name="respuesta" value="${escapeHtml(m.id)}"${i === 0 ? " checked" : ""}>` +
|
|
332
|
+
`<span>${escapeHtml(m.label)}</span></label>`)
|
|
333
|
+
.join("");
|
|
334
|
+
return page("Segundo factor", "<p>Tu cuenta de Microsoft pide un segundo factor. ¿Con cuál seguís?</p>" + formWith(options, "Seguir"));
|
|
335
|
+
}
|
|
336
|
+
if (prompt.step === "code") {
|
|
337
|
+
const error = prompt.retry ? "<p class=error>El código no era correcto. Probá de nuevo.</p>" : "";
|
|
338
|
+
const field = `<label>${escapeHtml(prompt.method.label)}<input name="respuesta" inputmode="numeric" ` +
|
|
339
|
+
`autocomplete="one-time-code" autocapitalize="none" required></label>`;
|
|
340
|
+
return page("Segundo factor", error + formWith(field, "Verificar"));
|
|
341
|
+
}
|
|
342
|
+
const number = prompt.number ? `<p>Cuando te lo pida, tocá este número:</p><p class=numero>${prompt.number}</p>` : "";
|
|
343
|
+
return page("Segundo factor", `<p>Aprobá el inicio de sesión en el celular (${escapeHtml(prompt.method.label)}).</p>` +
|
|
344
|
+
number +
|
|
345
|
+
formWith(`<input type="hidden" name="respuesta" value="ok">`, "Ya aprobé"));
|
|
346
|
+
}
|
|
302
347
|
function loginPage(o) {
|
|
303
348
|
const where = o.redirectUri ? `<strong>${escapeHtml(describeRedirect(o.redirectUri))}</strong>` : "";
|
|
304
349
|
const destination = where && !o.unknownRedirect ? ` Al terminar vas a volver a ${where}: si no es la app que estás conectando, no sigas.` : "";
|
package/build/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
|
|
|
7
7
|
export function createServer(aula, files) {
|
|
8
8
|
const server = new McpServer({
|
|
9
9
|
name: "UTN-MCP",
|
|
10
|
-
version: "0.8.
|
|
10
|
+
version: "0.8.3",
|
|
11
11
|
});
|
|
12
12
|
registerAulaVirtualTools(server, aula, files);
|
|
13
13
|
return server;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { PDFArray, PDFDocument, PDFName, StandardFonts, degrees, rgb } from "pdf-lib";
|
|
2
|
+
/** Filas del índice que entran en una página A4 con letra de 11pt. */
|
|
3
|
+
const INDEX_ROWS_PER_PAGE = 40;
|
|
4
|
+
/**
|
|
5
|
+
* Alto de la franja que se AGREGA arriba de cada página de contenido para el enlace de
|
|
6
|
+
* vuelta al índice: la página queda un poco más alta, así el enlace nunca tapa el material.
|
|
7
|
+
*/
|
|
8
|
+
const BACK_STRIP = 18;
|
|
9
|
+
const BACK_LABEL = "Volver al índice";
|
|
10
|
+
/**
|
|
11
|
+
* Agrega a `page` un enlace rectangular (anotación Link sin borde) que muestra `dest` entera.
|
|
12
|
+
*/
|
|
13
|
+
function addPageLink(out, page, rect, dest) {
|
|
14
|
+
const link = out.context.register(out.context.obj({
|
|
15
|
+
Type: "Annot",
|
|
16
|
+
Subtype: "Link",
|
|
17
|
+
Rect: rect,
|
|
18
|
+
Border: [0, 0, 0],
|
|
19
|
+
Dest: [dest.ref, "Fit"],
|
|
20
|
+
}));
|
|
21
|
+
let annots = page.node.lookupMaybe(PDFName.of("Annots"), PDFArray);
|
|
22
|
+
if (!annots) {
|
|
23
|
+
annots = out.context.obj([]);
|
|
24
|
+
page.node.set(PDFName.of("Annots"), annots);
|
|
25
|
+
}
|
|
26
|
+
annots.push(link);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Une PDFs en uno solo, con un índice clickeable al principio: cada entrada abre la página
|
|
30
|
+
* donde empieza ese material, y cada página de contenido tiene arriba un enlace "Volver al
|
|
31
|
+
* índice" (en una franja agregada, sin tapar nada). El que no se puede abrir (roto o con
|
|
32
|
+
* clave) se salta y queda en `skipped`: pdf-lib no desencripta, así que un PDF con clave
|
|
33
|
+
* entraría con páginas rotas. El índice se arma al final, cuando se sabe qué archivos
|
|
34
|
+
* entraron, y se inserta al frente.
|
|
35
|
+
*/
|
|
36
|
+
export async function mergePdfs(files, documentTitle) {
|
|
37
|
+
const out = await PDFDocument.create();
|
|
38
|
+
if (documentTitle)
|
|
39
|
+
out.setTitle(documentTitle);
|
|
40
|
+
const merged = [];
|
|
41
|
+
/** En qué página empieza cada uno sin contar el índice, que todavía no se sabe cuánto ocupa. */
|
|
42
|
+
const contentStarts = [];
|
|
43
|
+
const skipped = [];
|
|
44
|
+
for (let index = 0; index < files.length; index++) {
|
|
45
|
+
const file = files[index];
|
|
46
|
+
let src;
|
|
47
|
+
try {
|
|
48
|
+
// ignoreEncryption solo para que `load` no rechace el archivo: así lo listamos
|
|
49
|
+
// en `skipped` con el motivo en vez de romper todo el pack.
|
|
50
|
+
src = await PDFDocument.load(file.bytes, { ignoreEncryption: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
skipped.push({ index, title: file.title, reason: "no se pudo abrir (está roto)" });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (src.isEncrypted) {
|
|
57
|
+
skipped.push({ index, title: file.title, reason: "tiene clave" });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let pages;
|
|
61
|
+
try {
|
|
62
|
+
pages = await out.copyPages(src, src.getPageIndices());
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Abre bien pero tiene objetos que no se pueden copiar: se salta como los rotos.
|
|
66
|
+
skipped.push({ index, title: file.title, reason: "no se pudieron copiar sus páginas" });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!pages.length) {
|
|
70
|
+
skipped.push({ index, title: file.title, reason: "no tiene páginas" });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
contentStarts.push(out.getPageCount() + 1);
|
|
74
|
+
for (const page of pages)
|
|
75
|
+
out.addPage(page);
|
|
76
|
+
merged.push({ index, title: file.title, pages: pages.length });
|
|
77
|
+
}
|
|
78
|
+
if (!merged.length)
|
|
79
|
+
throw new Error("No se pudo unir ninguno de los PDF.");
|
|
80
|
+
const indexPages = Math.ceil(merged.length / INDEX_ROWS_PER_PAGE);
|
|
81
|
+
const withStart = merged.map((m, i) => ({ ...m, startPage: contentStarts[i] + indexPages }));
|
|
82
|
+
const font = await out.embedFont(StandardFonts.Helvetica);
|
|
83
|
+
await writeIndex(out, withStart, indexPages, font);
|
|
84
|
+
addBackLinks(out, indexPages, font);
|
|
85
|
+
const bytes = await out.save();
|
|
86
|
+
return { bytes, pages: out.getPageCount(), merged: withStart, skipped };
|
|
87
|
+
}
|
|
88
|
+
/** Escribe las páginas del índice y las inserta al frente, con cada renglón enlazado a su material. */
|
|
89
|
+
async function writeIndex(out, merged, indexPages, font) {
|
|
90
|
+
const bold = await out.embedFont(StandardFonts.HelveticaBold);
|
|
91
|
+
const pageSize = [595, 842]; // A4
|
|
92
|
+
const margin = 50;
|
|
93
|
+
const lineHeight = 16;
|
|
94
|
+
const pages = [];
|
|
95
|
+
for (let n = 0; n < indexPages; n++) {
|
|
96
|
+
const page = out.insertPage(n, pageSize);
|
|
97
|
+
page.drawText(winAnsi("Índice"), { x: margin, y: pageSize[1] - margin, size: 16, font: bold });
|
|
98
|
+
pages.push(page);
|
|
99
|
+
}
|
|
100
|
+
merged.forEach((row, i) => {
|
|
101
|
+
const page = pages[Math.floor(i / INDEX_ROWS_PER_PAGE)];
|
|
102
|
+
const y = pageSize[1] - margin - lineHeight * (2 + (i % INDEX_ROWS_PER_PAGE));
|
|
103
|
+
const pageNo = String(row.startPage);
|
|
104
|
+
page.drawText(pageNo, { x: margin + 30 - font.widthOfTextAtSize(pageNo, 11), y, size: 11, font });
|
|
105
|
+
page.drawText(truncate(winAnsi(row.title), font, 11, pageSize[0] - margin * 2 - 40), {
|
|
106
|
+
x: margin + 40,
|
|
107
|
+
y,
|
|
108
|
+
size: 11,
|
|
109
|
+
font,
|
|
110
|
+
});
|
|
111
|
+
addPageLink(out, page, [margin, y - 4, pageSize[0] - margin, y + 11], out.getPage(row.startPage - 1));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Agrega a cada página de contenido una franja vacía con "Volver al índice" enlazado a la
|
|
116
|
+
* primera página del índice. La franja AGRANDA la página por el borde que queda arriba al
|
|
117
|
+
* leerla (arriba sin rotación; a la izquierda, abajo o a la derecha según la rotación de la
|
|
118
|
+
* página) y el texto se dibuja ya rotado: se lee horizontal en cualquier orientación y, al
|
|
119
|
+
* ser espacio agregado, nunca tapa el material. Toda la franja es clickeable.
|
|
120
|
+
*/
|
|
121
|
+
function addBackLinks(out, indexPages, font) {
|
|
122
|
+
const firstIndexPage = out.getPage(0);
|
|
123
|
+
const size = 9;
|
|
124
|
+
const color = rgb(0.1, 0.3, 0.7);
|
|
125
|
+
const textWidth = font.widthOfTextAtSize(BACK_LABEL, size);
|
|
126
|
+
const total = out.getPageCount();
|
|
127
|
+
for (let i = indexPages; i < total; i++) {
|
|
128
|
+
const page = out.getPage(i);
|
|
129
|
+
// Los dos box se leen ANTES de escribir: sin CropBox propio, getCropBox devuelve el
|
|
130
|
+
// MediaBox, y leerlo después de agrandarlo agrandaría el CropBox dos veces.
|
|
131
|
+
const media = page.getMediaBox();
|
|
132
|
+
const crop = page.getCropBox();
|
|
133
|
+
const { x: X, y: Y, width: w, height: h } = media;
|
|
134
|
+
const rotation = (((page.getRotation().angle % 360) + 360) % 360);
|
|
135
|
+
// Posición del texto y dirección del subrayado según el borde por donde crece la página.
|
|
136
|
+
let textX, textY, lineStart, lineEnd;
|
|
137
|
+
let strip;
|
|
138
|
+
let rotate;
|
|
139
|
+
switch (rotation) {
|
|
140
|
+
case 90:
|
|
141
|
+
// Al leerla rotada 90° el borde izquierdo queda arriba: la franja crece a la izquierda.
|
|
142
|
+
page.setMediaBox(X - BACK_STRIP, Y, w + BACK_STRIP, h);
|
|
143
|
+
page.setCropBox(crop.x - BACK_STRIP, crop.y, crop.width + BACK_STRIP, crop.height);
|
|
144
|
+
strip = [X - BACK_STRIP, Y, X, Y + h];
|
|
145
|
+
rotate = degrees(90);
|
|
146
|
+
textX = X - BACK_STRIP + 5 + size;
|
|
147
|
+
textY = Y + h - 30 - textWidth;
|
|
148
|
+
lineStart = { x: textX + 1.5, y: textY };
|
|
149
|
+
lineEnd = { x: textX + 1.5, y: textY + textWidth };
|
|
150
|
+
break;
|
|
151
|
+
case 180:
|
|
152
|
+
page.setMediaBox(X, Y - BACK_STRIP, w, h + BACK_STRIP);
|
|
153
|
+
page.setCropBox(crop.x, crop.y - BACK_STRIP, crop.width, crop.height + BACK_STRIP);
|
|
154
|
+
strip = [X, Y - BACK_STRIP, X + w, Y];
|
|
155
|
+
rotate = degrees(180);
|
|
156
|
+
textX = X + 30 + textWidth;
|
|
157
|
+
textY = Y - BACK_STRIP + 5 + size;
|
|
158
|
+
lineStart = { x: textX, y: textY + 1.5 };
|
|
159
|
+
lineEnd = { x: textX - textWidth, y: textY + 1.5 };
|
|
160
|
+
break;
|
|
161
|
+
case 270:
|
|
162
|
+
page.setMediaBox(X, Y, w + BACK_STRIP, h);
|
|
163
|
+
page.setCropBox(crop.x, crop.y, crop.width + BACK_STRIP, crop.height);
|
|
164
|
+
strip = [X + w, Y, X + w + BACK_STRIP, Y + h];
|
|
165
|
+
rotate = degrees(270);
|
|
166
|
+
textX = X + w + BACK_STRIP - 5 - size;
|
|
167
|
+
textY = Y + 30 + textWidth;
|
|
168
|
+
lineStart = { x: textX - 1.5, y: textY };
|
|
169
|
+
lineEnd = { x: textX - 1.5, y: textY - textWidth };
|
|
170
|
+
break;
|
|
171
|
+
default:
|
|
172
|
+
page.setMediaBox(X, Y, w, h + BACK_STRIP);
|
|
173
|
+
page.setCropBox(crop.x, crop.y, crop.width, crop.height + BACK_STRIP);
|
|
174
|
+
strip = [X, Y + h, X + w, Y + h + BACK_STRIP];
|
|
175
|
+
rotate = degrees(0);
|
|
176
|
+
textX = X + w - 30 - textWidth;
|
|
177
|
+
textY = Y + h + 5;
|
|
178
|
+
lineStart = { x: textX, y: textY - 1.5 };
|
|
179
|
+
lineEnd = { x: textX + textWidth, y: textY - 1.5 };
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
page.drawText(BACK_LABEL, { x: textX, y: textY, size, font, color, rotate });
|
|
183
|
+
page.drawLine({ start: lineStart, end: lineEnd, thickness: 0.5, color });
|
|
184
|
+
addPageLink(out, page, strip, firstIndexPage);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Lo que la letra base del PDF sabe dibujar (WinAnsi); lo demás se cambia por "?". */
|
|
188
|
+
const winAnsi = (text) => text.replace(/[^\x20-\x7E\xA0-\xFF\u2013\u2014\u2018\u2019\u201C\u201D\u2022\u2026]/g, "?");
|
|
189
|
+
/** Recorta el título para que entre en el renglón, con "…" si se cortó. */
|
|
190
|
+
function truncate(text, font, size, maxWidth) {
|
|
191
|
+
if (font.widthOfTextAtSize(text, size) <= maxWidth)
|
|
192
|
+
return text;
|
|
193
|
+
let cut = text;
|
|
194
|
+
while (cut && font.widthOfTextAtSize(cut + "…", size) > maxWidth)
|
|
195
|
+
cut = cut.slice(0, -1);
|
|
196
|
+
return cut + "…";
|
|
197
|
+
}
|
package/build/shared/pages.js
CHANGED
|
@@ -41,7 +41,8 @@ button:hover{background:var(--accent)}
|
|
|
41
41
|
.aviso{border:1px solid var(--err);padding:0 1rem;margin:1rem 0}
|
|
42
42
|
label.acepto{flex-direction:row;align-items:flex-start;gap:.6rem;font:13px/1.5 ui-sans-serif,system-ui,sans-serif;
|
|
43
43
|
text-transform:none;letter-spacing:normal;color:var(--ink)}
|
|
44
|
-
input[type=checkbox]{padding:0;margin:.2rem 0 0;accent-color:var(--accent)}
|
|
44
|
+
input[type=checkbox],input[type=radio]{padding:0;margin:.2rem 0 0;accent-color:var(--accent)}
|
|
45
|
+
.numero{margin:1rem 0;font:600 2.5rem/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;text-align:center}
|
|
45
46
|
ul{padding-left:1.2rem}
|
|
46
47
|
::selection{background:var(--accent);color:var(--paper)}
|
|
47
48
|
`.replace(/\n/g, "");
|
package/build/shared/paths.js
CHANGED
|
@@ -15,3 +15,7 @@ export const sessionFile = () => join(configDir(), "session.json");
|
|
|
15
15
|
export const storeFile = () => process.env.UTN_MCP_DB ?? join(configDir(), "utn-mcp.db");
|
|
16
16
|
/** Clave con la que se cifran las sesiones en la base, si no viene en UTN_MCP_KEY. */
|
|
17
17
|
export const storeKeyFile = () => join(configDir(), "store.key");
|
|
18
|
+
/** Dónde quedan los archivos que arma el servidor (packs de estudio): UTN_MCP_EXPORTS o exports/ en la configuración. */
|
|
19
|
+
export function exportsDir() {
|
|
20
|
+
return process.env.UTN_MCP_EXPORTS ?? join(configDir(), "exports");
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "utn-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Este es el Model Context Protocol server NO OFICIAL de la UTN para buscar informacion o archivos en el aula virtual.",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"@modelcontextprotocol/server": "^2.3.0",
|
|
43
43
|
"@napi-rs/keyring": "^2.1.0",
|
|
44
44
|
"express": "^5.2.1",
|
|
45
|
+
"pdf-lib": "^1.17.1",
|
|
45
46
|
"tough-cookie": "^6.0.2",
|
|
46
47
|
"zod": "^4.6.5"
|
|
47
48
|
},
|