utn-mcp 0.2.0 → 0.6.7

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.
@@ -1,72 +1,73 @@
1
1
  import { inflateRawSync } from "node:zlib";
2
- const FIN_DIRECTORIO = 0x06054b50;
3
- const DIRECTORIO = 0x02014b50;
4
- const ENCABEZADO_LOCAL = 0x04034b50;
5
- const SIN_COMPRESION = 0;
2
+ const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
3
+ const CENTRAL_DIRECTORY_ENTRY = 0x02014b50;
4
+ const LOCAL_HEADER = 0x04034b50;
5
+ const STORED = 0;
6
6
  const DEFLATE = 8;
7
- class ZipInvalido extends Error {
8
- constructor(detalle) {
9
- super(`El archivo no es un zip válido: ${detalle}.`);
10
- this.name = "ZipInvalido";
7
+ class InvalidZip extends Error {
8
+ constructor(detail) {
9
+ super(`El archivo no es un zip válido: ${detail}.`);
10
+ this.name = "InvalidZip";
11
11
  }
12
12
  }
13
- export function listarZip(bytes) {
14
- const vista = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13
+ export function listZip(bytes) {
14
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
15
15
  // El fin del directorio está al final, seguido de un comentario opcional de hasta 64 KB.
16
- let fin = -1;
16
+ let end = -1;
17
17
  for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--) {
18
- if (vista.getUint32(i, true) === FIN_DIRECTORIO) {
19
- fin = i;
18
+ if (view.getUint32(i, true) === END_OF_CENTRAL_DIRECTORY) {
19
+ end = i;
20
20
  break;
21
21
  }
22
22
  }
23
- if (fin < 0)
24
- throw new ZipInvalido("no tiene índice");
25
- const cantidad = vista.getUint16(fin + 10, true);
26
- let p = vista.getUint32(fin + 16, true);
27
- if (cantidad === 0xffff || p === 0xffffffff)
28
- throw new ZipInvalido("usa zip64");
29
- const entradas = [];
30
- const nombres = new TextDecoder();
31
- for (let i = 0; i < cantidad; i++) {
32
- if (p + 46 > bytes.length || vista.getUint32(p, true) !== DIRECTORIO)
33
- throw new ZipInvalido("índice corrupto");
34
- const largoNombre = vista.getUint16(p + 28, true);
35
- entradas.push({
36
- nombre: nombres.decode(bytes.subarray(p + 46, p + 46 + largoNombre)),
37
- metodo: vista.getUint16(p + 10, true),
38
- comprimido: vista.getUint32(p + 20, true),
39
- tamanio: vista.getUint32(p + 24, true),
40
- offset: vista.getUint32(p + 42, true),
23
+ if (end < 0)
24
+ throw new InvalidZip("no tiene índice");
25
+ const count = view.getUint16(end + 10, true);
26
+ let p = view.getUint32(end + 16, true);
27
+ if (count === 0xffff || p === 0xffffffff)
28
+ throw new InvalidZip("usa zip64");
29
+ const entries = [];
30
+ const decoder = new TextDecoder();
31
+ for (let i = 0; i < count; i++) {
32
+ if (p + 46 > bytes.length || view.getUint32(p, true) !== CENTRAL_DIRECTORY_ENTRY) {
33
+ throw new InvalidZip("índice corrupto");
34
+ }
35
+ const nameLength = view.getUint16(p + 28, true);
36
+ entries.push({
37
+ name: decoder.decode(bytes.subarray(p + 46, p + 46 + nameLength)),
38
+ method: view.getUint16(p + 10, true),
39
+ compressedSize: view.getUint32(p + 20, true),
40
+ size: view.getUint32(p + 24, true),
41
+ offset: view.getUint32(p + 42, true),
41
42
  });
42
- p += 46 + largoNombre + vista.getUint16(p + 30, true) + vista.getUint16(p + 32, true);
43
+ p += 46 + nameLength + view.getUint16(p + 30, true) + view.getUint16(p + 32, true);
43
44
  }
44
- return entradas;
45
+ return entries;
45
46
  }
46
47
  /**
47
48
  * Descomprime una entrada. `max` corta aunque el índice mienta sobre el tamaño: un zip chico que se
48
49
  * infla a gigas es un ataque conocido.
49
50
  */
50
- export function leerEntrada(bytes, entrada, max) {
51
- if (entrada.tamanio > max)
52
- throw new ZipInvalido(`"${entrada.nombre}" descomprimido pesa demasiado`);
53
- const vista = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54
- const p = entrada.offset;
55
- if (vista.getUint32(p, true) !== ENCABEZADO_LOCAL)
56
- throw new ZipInvalido(`falta el encabezado de "${entrada.nombre}"`);
57
- const inicio = p + 30 + vista.getUint16(p + 26, true) + vista.getUint16(p + 28, true);
58
- const datos = bytes.subarray(inicio, inicio + entrada.comprimido);
59
- switch (entrada.metodo) {
60
- case SIN_COMPRESION:
61
- return datos;
51
+ export function readEntry(bytes, entry, max) {
52
+ if (entry.size > max)
53
+ throw new InvalidZip(`"${entry.name}" descomprimido pesa demasiado`);
54
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
55
+ const p = entry.offset;
56
+ if (view.getUint32(p, true) !== LOCAL_HEADER)
57
+ throw new InvalidZip(`falta el encabezado de "${entry.name}"`);
58
+ const start = p + 30 + view.getUint16(p + 26, true) + view.getUint16(p + 28, true);
59
+ const data = bytes.subarray(start, start + entry.compressedSize);
60
+ switch (entry.method) {
61
+ case STORED:
62
+ return data;
62
63
  case DEFLATE:
63
64
  try {
64
- return inflateRawSync(datos, { maxOutputLength: max });
65
+ return inflateRawSync(data, { maxOutputLength: max });
65
66
  }
66
67
  catch (e) {
67
- throw new ZipInvalido(`no se pudo descomprimir "${entrada.nombre}" (${e.message})`);
68
+ throw new InvalidZip(`no se pudo descomprimir "${entry.name}" (${e.message})`);
68
69
  }
69
70
  default:
70
- throw new ZipInvalido(`"${entrada.nombre}" usa una compresión no soportada (${entrada.metodo})`);
71
+ throw new InvalidZip(`"${entry.name}" usa una compresión no soportada (${entry.method})`);
71
72
  }
72
73
  }
@@ -14,6 +14,19 @@ export function htmlToText(html) {
14
14
  }
15
15
  /** Como htmlToText pero recortado: para índices, donde solo hace falta decidir si entrar. */
16
16
  export function htmlToSnippet(html, max = 200) {
17
- const texto = htmlToText(html);
18
- return texto.length > max ? `${texto.slice(0, max).trimEnd()}…` : texto;
17
+ const text = htmlToText(html);
18
+ return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text;
19
+ }
20
+ /** Texto plano del modelo a HTML para Moodle: escapa, y cada línea en blanco separa un párrafo. */
21
+ export function textToHtml(text) {
22
+ const escaped = text
23
+ .trim()
24
+ .replace(/&/g, "&amp;")
25
+ .replace(/</g, "&lt;")
26
+ .replace(/>/g, "&gt;")
27
+ .replace(/"/g, "&quot;");
28
+ return escaped
29
+ .split(/\n\s*\n/)
30
+ .map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`)
31
+ .join("");
19
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utn-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.6.7",
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": {
@@ -36,7 +36,7 @@
36
36
  "bugs": {
37
37
  "url": "https://github.com/nialvape/utn-mcp/issues"
38
38
  },
39
- "homepage": "https://github.com/nialvape/utn-mcp#readme",
39
+ "homepage": "https://utnmcp.com.ar",
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/node": "^2.1.1",
42
42
  "@modelcontextprotocol/server": "^2.3.0",
@@ -1,39 +0,0 @@
1
- export const RAIZ = "/";
2
- export const cursoAddr = (courseId) => `/curso/${courseId}`;
3
- export const moduloAddr = (courseId, cmid) => `/curso/${courseId}/mod/${cmid}`;
4
- export const discusionAddr = (courseId, cmid, discussionId) => `/curso/${courseId}/mod/${cmid}/disc/${discussionId}`;
5
- const FORMATOS = [
6
- "/",
7
- "/curso/{courseId}",
8
- "/curso/{courseId}/mod/{cmid}",
9
- "/curso/{courseId}/mod/{cmid}/disc/{discussionId}",
10
- ].join(", ");
11
- const PATRON = /^\/curso\/(\d+)(?:\/mod\/(\d+)(?:\/disc\/(\d+))?)?$/;
12
- export function parseAddress(raw) {
13
- const path = raw.trim().replace(/\/+$/, "") || "/";
14
- if (path === "/")
15
- return { nivel: "raiz" };
16
- const m = PATRON.exec(path);
17
- if (!m)
18
- throw new Error(`Dirección inválida: "${raw}". Formatos válidos: ${FORMATOS}`);
19
- const [, curso, cmid, disc] = m;
20
- const courseId = Number(curso);
21
- if (!cmid)
22
- return { nivel: "curso", courseId };
23
- if (!disc)
24
- return { nivel: "modulo", courseId, cmid: Number(cmid) };
25
- return { nivel: "discusion", courseId, cmid: Number(cmid), discussionId: Number(disc) };
26
- }
27
- /** La dirección de un nivel más arriba, o null si ya estamos en la raíz. */
28
- export function padre(address) {
29
- switch (address.nivel) {
30
- case "raiz":
31
- return null;
32
- case "curso":
33
- return RAIZ;
34
- case "modulo":
35
- return cursoAddr(address.courseId);
36
- case "discusion":
37
- return moduloAddr(address.courseId, address.cmid);
38
- }
39
- }
@@ -1,89 +0,0 @@
1
- import { abrirDocumento, FormatoNoSoportado } from "../shared/documents/index.js";
2
- import { AULA_BASE_URL } from "./client/constants.js";
3
- import { fetchFile } from "./client/moodle-api.js";
4
- const ORIGEN = new URL(AULA_BASE_URL).origin;
5
- const RUTA_WS = "/webservice/pluginfile.php/";
6
- const RUTA_WEB = "/pluginfile.php/";
7
- /** Cuánto se devuelve por llamada, en caracteres de texto. */
8
- const PRESUPUESTO = 30_000;
9
- /** Lo que cuesta una página como imagen (~1.600 tokens), medido en caracteres de texto. */
10
- const COSTO_IMAGEN = 6_000;
11
- /** El más grande que hay hoy en los cursos pesa 12 MB. */
12
- const MAX_BYTES = 30 * 1024 * 1024;
13
- /**
14
- * Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
15
- * `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
16
- * sale nunca del servidor.
17
- */
18
- export function urlParaUsuario(fileurl) {
19
- return fileurl.replace(`${ORIGEN}${RUTA_WS}`, `${ORIGEN}${RUTA_WEB}`);
20
- }
21
- /**
22
- * Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula: con
23
- * cualquier otra URL estaríamos mandando el token del usuario a donde diga el modelo.
24
- */
25
- function urlDeDescarga(url) {
26
- let u;
27
- try {
28
- u = new URL(url);
29
- }
30
- catch {
31
- throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
32
- }
33
- const ruta = u.pathname.startsWith(RUTA_WS) ? u.pathname : u.pathname.startsWith(RUTA_WEB) ? RUTA_WS + u.pathname.slice(RUTA_WEB.length) : null;
34
- if (u.origin !== ORIGEN || !ruta) {
35
- throw new Error(`Solo se pueden leer archivos del aula virtual (${ORIGEN}${RUTA_WEB}...).`);
36
- }
37
- u.pathname = ruta;
38
- u.searchParams.delete("token");
39
- u.searchParams.delete("wstoken");
40
- return u;
41
- }
42
- /** Baja un archivo del aula y devuelve desde la parte `desde` tantas como entren en el presupuesto. */
43
- export async function leerArchivo(session, url, desde = 1) {
44
- const descarga = urlDeDescarga(url);
45
- const archivo = decodeURIComponent(descarga.pathname.split("/").pop() ?? "");
46
- const paraUsuario = urlParaUsuario(descarga.href);
47
- const { bytes } = await fetchFile(session, descarga, MAX_BYTES);
48
- let documento;
49
- try {
50
- documento = await abrirDocumento(bytes, archivo);
51
- }
52
- catch (e) {
53
- if (e instanceof FormatoNoSoportado) {
54
- throw new Error(`${e.message} El usuario lo puede bajar desde ${paraUsuario}`);
55
- }
56
- throw e;
57
- }
58
- try {
59
- if (desde > documento.total) {
60
- throw new Error(`"${archivo}" termina en ${documento.unidad} ${documento.total}; se pidió desde ${desde}.`);
61
- }
62
- const partes = [];
63
- let gastado = 0;
64
- let n = desde;
65
- for (; n <= documento.total; n++) {
66
- const parte = await documento.parte(n);
67
- const costo = "texto" in parte ? parte.texto.length : COSTO_IMAGEN;
68
- // Siempre al menos una parte, aunque sola se pase del presupuesto.
69
- if (partes.length > 0 && gastado + costo > PRESUPUESTO)
70
- break;
71
- partes.push({ ...parte, numero: n });
72
- gastado += costo;
73
- }
74
- return {
75
- archivo,
76
- formato: documento.formato,
77
- unidad: documento.unidad,
78
- total: documento.total,
79
- desde,
80
- hasta: n - 1,
81
- siguiente: n <= documento.total ? n : undefined,
82
- urlParaUsuario: paraUsuario,
83
- partes,
84
- };
85
- }
86
- finally {
87
- await documento.cerrar();
88
- }
89
- }
@@ -1,144 +0,0 @@
1
- import { htmlToSnippet, htmlToText } from "../shared/html.js";
2
- import { cursoAddr, discusionAddr, moduloAddr, padre, parseAddress, RAIZ } from "./address.js";
3
- import { getCourseSections, getDiscussionPosts, getForumDiscussions, getUserCourses, POR_PAGINA, } from "./client/client.js";
4
- import { urlParaUsuario } from "./files.js";
5
- const isoDate = (seconds) => (seconds ? new Date(seconds * 1000).toISOString() : null);
6
- /**
7
- * Entra a una dirección del aula y devuelve su contenido, el padre y las direcciones de lo que hay
8
- * adentro. Es el único lugar que decide qué ve el modelo y cuánto texto le cuesta.
9
- */
10
- export async function open(session, direccion, pagina = 0) {
11
- const address = parseAddress(direccion);
12
- const base = { direccion: formatear(address), padre: padre(address) };
13
- switch (address.nivel) {
14
- case "raiz":
15
- return { ...base, cursos: await verCursos(session) };
16
- case "curso":
17
- return { ...base, secciones: await verCurso(session, address.courseId) };
18
- case "modulo":
19
- return { ...base, ...(await verModulo(session, address, pagina)) };
20
- case "discusion":
21
- return { ...base, ...(await verDiscusion(session, address.discussionId)) };
22
- }
23
- }
24
- function formatear(address) {
25
- switch (address.nivel) {
26
- case "raiz":
27
- return RAIZ;
28
- case "curso":
29
- return cursoAddr(address.courseId);
30
- case "modulo":
31
- return moduloAddr(address.courseId, address.cmid);
32
- case "discusion":
33
- return discusionAddr(address.courseId, address.cmid, address.discussionId);
34
- }
35
- }
36
- async function verCursos(session) {
37
- const courses = await getUserCourses(session);
38
- return courses.map((c) => ({
39
- direccion: cursoAddr(c.id),
40
- id: c.id,
41
- nombre: c.fullname,
42
- nombreCorto: c.shortname,
43
- visible: c.visible === 1,
44
- progreso: c.progress ?? null,
45
- ultimoAcceso: isoDate(c.lastaccess),
46
- }));
47
- }
48
- /** El mapa del curso: secciones y módulos, con las descripciones recortadas. */
49
- async function verCurso(session, courseId) {
50
- const sections = await getCourseSections(session, courseId);
51
- return sections
52
- .filter((s) => s.uservisible !== false)
53
- .map((s) => ({
54
- nombre: s.name,
55
- numero: s.section,
56
- resumen: htmlToText(s.summary) || undefined,
57
- modulos: s.modules.filter((m) => m.uservisible).map((m) => toModule(m, courseId, true)),
58
- }))
59
- .filter((s) => s.resumen || s.modulos.length > 0);
60
- }
61
- async function verModulo(session, address, pagina) {
62
- const { seccion, module } = await buscarModulo(session, address.courseId, address.cmid);
63
- const modulo = toModule(module, address.courseId);
64
- if (module.modname !== "forum")
65
- return { seccion, modulo };
66
- // Un foro es un índice: asuntos y un fragmento de cada uno. El mensaje completo sale al abrir la discusión.
67
- const { discussions } = await getForumDiscussions(session, module.instance, pagina);
68
- return {
69
- seccion,
70
- modulo,
71
- discusiones: discussions.map((d) => ({
72
- direccion: discusionAddr(address.courseId, address.cmid, d.discussion),
73
- asunto: d.subject,
74
- autor: d.userfullname,
75
- fecha: isoDate(d.created),
76
- ultimaActividad: isoDate(d.timemodified),
77
- respuestas: d.numreplies,
78
- fijada: d.pinned || undefined,
79
- adjuntos: d.attachment || undefined,
80
- resumen: htmlToSnippet(d.message),
81
- })),
82
- pagina,
83
- hayMasDiscusiones: discussions.length === POR_PAGINA || undefined,
84
- };
85
- }
86
- async function verDiscusion(session, discussionId) {
87
- const { posts } = await getDiscussionPosts(session, discussionId);
88
- const visibles = posts.filter((p) => !p.isdeleted);
89
- return {
90
- asunto: visibles[0]?.subject,
91
- publicaciones: visibles.map((p) => ({
92
- autor: p.author.fullname,
93
- fecha: isoDate(p.timecreated),
94
- respuestaA: p.parentid ?? undefined,
95
- id: p.id,
96
- mensaje: htmlToText(p.message),
97
- adjuntos: p.attachments.length ? p.attachments.map(toFile) : undefined,
98
- })),
99
- };
100
- }
101
- /** Ubica un módulo por su cmid y se queda con la sección que lo contiene. */
102
- async function buscarModulo(session, courseId, cmid) {
103
- const sections = await getCourseSections(session, courseId, cmid);
104
- for (const section of sections) {
105
- const module = section.modules.find((m) => m.id === cmid);
106
- if (!module)
107
- continue;
108
- if (!module.uservisible)
109
- throw new Error(`No tenés acceso al módulo ${cmid}.`);
110
- return { seccion: section.name, module };
111
- }
112
- throw new Error(`No existe el módulo ${cmid} en el curso ${courseId}.`);
113
- }
114
- /** `resumido` recorta la descripción: en el mapa del curso alcanza para decidir si entrar. */
115
- function toModule(m, courseId, resumido = false) {
116
- const contents = m.contents ?? [];
117
- const archivos = contents.filter((c) => c.type === "file");
118
- const enlaces = contents.filter((c) => c.type === "url");
119
- return {
120
- direccion: moduloAddr(courseId, m.id),
121
- id: m.id,
122
- nombre: m.name,
123
- tipo: m.modname,
124
- url: m.url,
125
- descripcion: (resumido ? htmlToSnippet(m.description) : htmlToText(m.description)) || undefined,
126
- restriccion: htmlToText(m.availabilityinfo) || undefined,
127
- fechas: m.dates?.length
128
- ? m.dates.map((d) => ({ etiqueta: d.label, fecha: isoDate(d.timestamp) }))
129
- : undefined,
130
- archivos: archivos.length ? archivos.map(toFile) : undefined,
131
- enlaces: enlaces.length ? enlaces.map((l) => l.fileurl) : undefined,
132
- };
133
- }
134
- function toFile(f) {
135
- return {
136
- nombre: f.filename,
137
- ruta: f.filepath || undefined,
138
- tamanio: f.filesize,
139
- mimetype: f.mimetype,
140
- modificado: isoDate(f.timemodified),
141
- // Sirve para pasársela al usuario y también para leerlo con aula_read_file.
142
- url: urlParaUsuario(f.fileurl),
143
- };
144
- }