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.
@@ -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", {
@@ -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 account = await linkAulaAccount(store, regional, { username, password });
122
+ const step = await flow.next();
123
+ if ("prompt" in step) {
124
+ return void res.json({ segundoFactor: secondFactorJson(id ?? secondFactors.add(verification), step.prompt) });
125
+ }
126
+ if (id)
127
+ secondFactors.take(id);
128
+ const account = step.done;
99
129
  logger.info(`Admin: ${account.username} (${regional.id}) vinculó su aula.`);
100
130
  res.json({
101
131
  usuario: account.username,
@@ -105,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
+ }
@@ -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
- const account = await login(req, list, retry);
147
- if (!account)
148
- return;
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
- pending.take(id);
153
- const code = codes.add({
154
- userId: account.userId,
155
- clientId: request.clientId,
156
- redirectUri: request.redirectUri,
157
- codeChallenge: request.codeChallenge,
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 account = await login(req, list, (error) => res.type("html").send(loginPage({
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
- if (!account)
223
- return;
224
- logger.info(`OAuth: ${account.username} (${account.regional.id}) renovó su sesión del aula.`);
225
- res.type("html").send(page("Listo", "<p>Ya renovamos tu sesión del aula. Volvé al chat y pedile al agente que reintente.</p>"));
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
- return await linkAulaAccount(store, regional, { username, password });
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.1",
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
+ }
@@ -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, "");
@@ -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.1",
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
  },