utn-mcp 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/build/aula-virtual/client/client.d.ts +97 -0
- package/build/aula-virtual/client/client.js +21 -0
- package/build/aula-virtual/client/credentials.d.ts +10 -0
- package/build/aula-virtual/client/link.d.ts +21 -0
- package/build/aula-virtual/client/local-session.d.ts +23 -0
- package/build/aula-virtual/client/moodle-api.d.ts +25 -0
- package/build/aula-virtual/client/moodle-api.js +28 -4
- package/build/aula-virtual/client/regionals.d.ts +47 -0
- package/build/aula-virtual/client/session.d.ts +12 -0
- package/build/aula-virtual/client/sso-login.d.ts +60 -0
- package/build/aula-virtual/client/stored-session.d.ts +32 -0
- package/build/aula-virtual/client/types.d.ts +173 -0
- package/build/aula-virtual/format/actions.d.ts +57 -0
- package/build/aula-virtual/format/activities.d.ts +86 -0
- package/build/aula-virtual/format/address.d.ts +36 -0
- package/build/aula-virtual/format/address.js +11 -0
- package/build/aula-virtual/format/fields.d.ts +11 -0
- package/build/aula-virtual/format/files.d.ts +32 -0
- package/build/aula-virtual/format/files.js +2 -2
- package/build/aula-virtual/format/navigate.d.ts +213 -0
- package/build/aula-virtual/format/study-pack.d.ts +33 -0
- package/build/aula-virtual/format/study-pack.js +199 -0
- package/build/aula-virtual/format/submission.d.ts +40 -0
- package/build/aula-virtual/login.d.ts +6 -0
- package/build/aula-virtual/tools.d.ts +4 -0
- package/build/aula-virtual/tools.js +40 -0
- package/build/autogestion/client/catalog.d.ts +20 -0
- package/build/autogestion/client/catalog.js +22 -0
- package/build/autogestion/client/connector.d.ts +61 -0
- package/build/autogestion/client/connector.js +31 -0
- package/build/autogestion/client/credentials.d.ts +5 -0
- package/build/autogestion/client/credentials.js +40 -0
- package/build/autogestion/client/install.d.ts +14 -0
- package/build/autogestion/client/install.js +72 -0
- package/build/autogestion/client/installed-systems.d.ts +7 -0
- package/build/autogestion/client/installed-systems.js +58 -0
- package/build/autogestion/client/local-session.d.ts +17 -0
- package/build/autogestion/client/local-session.js +37 -0
- package/build/autogestion/client/session.d.ts +14 -0
- package/build/autogestion/client/session.js +1 -0
- package/build/autogestion/client/types.d.ts +49 -0
- package/build/autogestion/client/types.js +1 -0
- package/build/autogestion/format/correlatividades.d.ts +4 -0
- package/build/autogestion/format/correlatividades.js +4 -0
- package/build/autogestion/format/estado-academico.d.ts +4 -0
- package/build/autogestion/format/estado-academico.js +4 -0
- package/build/autogestion/format/examenes.d.ts +3 -0
- package/build/autogestion/format/examenes.js +8 -0
- package/build/autogestion/format/notas-parciales.d.ts +3 -0
- package/build/autogestion/format/notas-parciales.js +8 -0
- package/build/autogestion/login.d.ts +11 -0
- package/build/autogestion/login.js +59 -0
- package/build/autogestion/tools.d.ts +3 -0
- package/build/autogestion/tools.js +73 -0
- package/build/http.d.ts +22 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +11 -1
- package/build/oauth/access.d.ts +18 -0
- package/build/oauth/admin.d.ts +8 -0
- package/build/oauth/admin.js +51 -10
- package/build/oauth/expiring.d.ts +25 -0
- package/build/oauth/pending-login.d.ts +20 -0
- package/build/oauth/redirects.d.ts +20 -0
- package/build/oauth/server.d.ts +22 -0
- package/build/server.d.ts +9 -0
- package/build/server.js +21 -2
- package/build/shared/documents/index.d.ts +33 -0
- package/build/shared/documents/merge.d.ts +32 -0
- package/build/shared/documents/merge.js +197 -0
- package/build/shared/documents/office.d.ts +9 -0
- package/build/shared/documents/pdf.d.ts +2 -0
- package/build/shared/documents/png.d.ts +2 -0
- package/build/shared/documents/zip.d.ts +18 -0
- package/build/shared/html.d.ts +6 -0
- package/build/shared/html.js +7 -1
- package/build/shared/http.d.ts +6 -0
- package/build/shared/input.d.ts +7 -0
- package/build/shared/load-env.d.ts +1 -0
- package/build/shared/logger.d.ts +5 -0
- package/build/shared/pages.d.ts +6 -0
- package/build/shared/paths.d.ts +10 -0
- package/build/shared/paths.js +4 -0
- package/build/shared/store/session-store.d.ts +77 -0
- package/build/shared/store/sqlite-store.d.ts +36 -0
- package/build/shared/uploads.d.ts +62 -0
- package/package.json +9 -1
package/build/server.js
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
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
|
+
import { systemForRegional } from "./autogestion/client/connector.js";
|
|
6
|
+
import { registerAutogestionTools } from "./autogestion/tools.js";
|
|
7
|
+
import { logger } from "./shared/logger.js";
|
|
3
8
|
/**
|
|
4
9
|
* Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
|
|
5
10
|
* `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.
|
|
6
12
|
*/
|
|
7
|
-
export function createServer(aula, files) {
|
|
13
|
+
export async function createServer(aula, files) {
|
|
8
14
|
const server = new McpServer({
|
|
9
15
|
name: "UTN-MCP",
|
|
10
|
-
version: "0.
|
|
16
|
+
version: "0.9.0",
|
|
11
17
|
});
|
|
12
18
|
registerAulaVirtualTools(server, aula, files);
|
|
19
|
+
if (files.local) {
|
|
20
|
+
// Un conector roto no puede dejar sin aula virtual: si falla, seguimos sin las tools de autogestión.
|
|
21
|
+
try {
|
|
22
|
+
const regional = await aula.getRegional();
|
|
23
|
+
const systems = await loadInstalledSystems();
|
|
24
|
+
const system = systemForRegional(regional.id, systems);
|
|
25
|
+
if (system)
|
|
26
|
+
registerAutogestionTools(server, new LocalAutogestionSession(system, regional.id));
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
logger.warn(e.message);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
13
32
|
return server;
|
|
14
33
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lectura de archivos para el modelo: un documento se parte en unidades (páginas, diapositivas,
|
|
3
|
+
* fragmentos) que se piden de a una, así quien lee decide cuánto entra en una respuesta.
|
|
4
|
+
*/
|
|
5
|
+
export type Part = {
|
|
6
|
+
title?: string;
|
|
7
|
+
text: string;
|
|
8
|
+
} | {
|
|
9
|
+
title?: string;
|
|
10
|
+
image: Uint8Array;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
};
|
|
13
|
+
export interface ParsedDocument {
|
|
14
|
+
format: string;
|
|
15
|
+
/** Cómo se llama cada parte: "página", "diapositiva", "fragmento"... */
|
|
16
|
+
unit: string;
|
|
17
|
+
total: number;
|
|
18
|
+
/** Numerada desde 1. */
|
|
19
|
+
part(n: number): Promise<Part>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare class UnsupportedFormat extends Error {
|
|
23
|
+
readonly format: string;
|
|
24
|
+
constructor(format: string);
|
|
25
|
+
}
|
|
26
|
+
/** Tamaño de cada fragmento cuando un texto largo viene de una sola pieza (un docx, un html). */
|
|
27
|
+
export declare const CHUNK_SIZE = 15000;
|
|
28
|
+
/** Reconoce el formato por los primeros bytes; el nombre solo se usa para los de texto plano. */
|
|
29
|
+
export declare function openDocument(bytes: Uint8Array, name: string): Promise<ParsedDocument>;
|
|
30
|
+
/** Un documento que ya está entero en memoria. */
|
|
31
|
+
export declare function inMemory(format: string, unit: string, parts: Part[]): ParsedDocument;
|
|
32
|
+
/** Corta un texto largo en pedazos de hasta `max` caracteres, prefiriendo cortar en un salto de línea. */
|
|
33
|
+
export declare function splitText(text: string, max?: number): string[];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Un PDF a unir, con el nombre con que aparece en el índice. */
|
|
2
|
+
export interface MergeInput {
|
|
3
|
+
title: string;
|
|
4
|
+
bytes: Uint8Array;
|
|
5
|
+
}
|
|
6
|
+
export interface MergedFile {
|
|
7
|
+
/** El índice del archivo en la lista de entrada, para reconocerlo después. */
|
|
8
|
+
index: number;
|
|
9
|
+
title: string;
|
|
10
|
+
/** Página del PDF unido en la que empieza, numerada desde 1 (contando el índice). */
|
|
11
|
+
startPage: number;
|
|
12
|
+
pages: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SkippedFile {
|
|
15
|
+
index: number;
|
|
16
|
+
title: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Une PDFs en uno solo, con un índice clickeable al principio: cada entrada abre la página
|
|
21
|
+
* donde empieza ese material, y cada página de contenido tiene arriba un enlace "Volver al
|
|
22
|
+
* índice" (en una franja agregada, sin tapar nada). El que no se puede abrir (roto o con
|
|
23
|
+
* clave) se salta y queda en `skipped`: pdf-lib no desencripta, así que un PDF con clave
|
|
24
|
+
* entraría con páginas rotas. El índice se arma al final, cuando se sabe qué archivos
|
|
25
|
+
* entraron, y se inserta al frente.
|
|
26
|
+
*/
|
|
27
|
+
export declare function mergePdfs(files: MergeInput[], documentTitle?: string): Promise<{
|
|
28
|
+
bytes: Uint8Array;
|
|
29
|
+
pages: number;
|
|
30
|
+
merged: MergedFile[];
|
|
31
|
+
skipped: SkippedFile[];
|
|
32
|
+
}>;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ParsedDocument } from "./index.js";
|
|
2
|
+
/**
|
|
3
|
+
* docx, pptx y xlsx son zips de XML. Sacar el texto es leer unos pocos XML; no hace falta
|
|
4
|
+
* una librería de Office para eso.
|
|
5
|
+
*/
|
|
6
|
+
export type OfficeFormat = "docx" | "pptx" | "xlsx";
|
|
7
|
+
/** Mira la lista de entradas del zip sin descomprimir nada. */
|
|
8
|
+
export declare function detectOfficeFormat(bytes: Uint8Array): OfficeFormat | null;
|
|
9
|
+
export declare function openOffice(bytes: Uint8Array, format: OfficeFormat): ParsedDocument;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lector mínimo de zip, lo justo para docx/pptx/xlsx: lee el índice (directorio central) del final
|
|
3
|
+
* del archivo y descomprime las entradas pedidas. Sin zip64 ni cifrado, que Office no usa.
|
|
4
|
+
*/
|
|
5
|
+
export interface ZipEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
method: number;
|
|
8
|
+
compressedSize: number;
|
|
9
|
+
size: number;
|
|
10
|
+
/** Dónde empieza el encabezado local de la entrada. */
|
|
11
|
+
offset: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function listZip(bytes: Uint8Array): ZipEntry[];
|
|
14
|
+
/**
|
|
15
|
+
* Descomprime una entrada. `max` corta aunque el índice mienta sobre el tamaño: un zip chico que se
|
|
16
|
+
* infla a gigas es un ataque conocido.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readEntry(bytes: Uint8Array, entry: ZipEntry, max: number): Uint8Array;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** HTML de Moodle a texto plano: alcanza para que el modelo lo lea, no es un parser. */
|
|
2
|
+
export declare function htmlToText(html: string | undefined): string;
|
|
3
|
+
/** Como htmlToText pero recortado: para índices, donde solo hace falta decidir si entrar. */
|
|
4
|
+
export declare function htmlToSnippet(html: string | undefined, max?: number): string;
|
|
5
|
+
/** Texto plano del modelo a HTML para Moodle: escapa, y cada línea en blanco separa un párrafo. */
|
|
6
|
+
export declare function textToHtml(text: string): string;
|
package/build/shared/html.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
// Las con tildes/eñe son las que usan Sysacad y el aula para el español.
|
|
2
|
+
const ENTITIES = {
|
|
3
|
+
nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'",
|
|
4
|
+
aacute: "á", eacute: "é", iacute: "í", oacute: "ó", uacute: "ú", uuml: "ü",
|
|
5
|
+
Aacute: "Á", Eacute: "É", Iacute: "Í", Oacute: "Ó", Uacute: "Ú", Uuml: "Ü",
|
|
6
|
+
ntilde: "ñ", Ntilde: "Ñ", iexcl: "¡", iquest: "¿", deg: "°", ordm: "º", ordf: "ª",
|
|
7
|
+
};
|
|
2
8
|
/** HTML de Moodle a texto plano: alcanza para que el modelo lo lea, no es un parser. */
|
|
3
9
|
export function htmlToText(html) {
|
|
4
10
|
if (!html)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare class HttpError extends Error {
|
|
2
|
+
readonly status?: number | undefined;
|
|
3
|
+
constructor(message: string, status?: number | undefined);
|
|
4
|
+
}
|
|
5
|
+
/** POST application/x-www-form-urlencoded y parseo JSON, con timeout. */
|
|
6
|
+
export declare function postForm<T>(url: string, params: Record<string, string | number>, timeoutMs?: number): Promise<T>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function ask(question: string): Promise<string>;
|
|
2
|
+
/**
|
|
3
|
+
* Elige una de `options` con las flechas (o su número) y Enter; devuelve el índice. Sin terminal
|
|
4
|
+
* interactiva (stdin redirigido) muestra la lista numerada y pide el número. Enter solo elige `initial`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function askChoice(question: string, options: string[], initial?: number): Promise<number>;
|
|
7
|
+
export declare function askHidden(question: string): Promise<string>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Página mínima con el estilo del sitio del proyecto: se abre desde el celu, así que nada más que lo necesario.
|
|
3
|
+
* `kicker` es la etiqueta chica arriba del título.
|
|
4
|
+
*/
|
|
5
|
+
export declare function page(title: string, body: string, kicker?: string): string;
|
|
6
|
+
export declare const escapeHtml: (s: string) => string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Carpeta de configuración: UTN_MCP_HOME, %APPDATA% en Windows, o XDG/~/.config en Linux y macOS. */
|
|
2
|
+
export declare function configDir(): string;
|
|
3
|
+
/** Token de Moodle del modo local. */
|
|
4
|
+
export declare const sessionFile: () => string;
|
|
5
|
+
/** Base de usuarios, tokens y sesiones del modo HTTP: UTN_MCP_DB o un SQLite en la carpeta de configuración. */
|
|
6
|
+
export declare const storeFile: () => string;
|
|
7
|
+
/** Clave con la que se cifran las sesiones en la base, si no viene en UTN_MCP_KEY. */
|
|
8
|
+
export declare const storeKeyFile: () => string;
|
|
9
|
+
/** Dónde quedan los archivos que arma el servidor (packs de estudio): UTN_MCP_EXPORTS o exports/ en la configuración. */
|
|
10
|
+
export declare function exportsDir(): string;
|
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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dónde viven los usuarios del modo HTTP, sus tokens y sus sesiones en cada plataforma. El servidor
|
|
3
|
+
* MCP lee (y guarda la sesión cuando la renueva); los usuarios y tokens los crea otro proceso: el
|
|
4
|
+
* login por OAuth de la versión hosteada o `utn-mcp login --http` en local.
|
|
5
|
+
*/
|
|
6
|
+
export interface SessionStore {
|
|
7
|
+
/**
|
|
8
|
+
* Id del usuario con ese nombre en esa regional; lo crea si no existe. Con `email`, se lo actualiza.
|
|
9
|
+
* Dos regionales pueden tener el mismo nombre de usuario para personas distintas.
|
|
10
|
+
*/
|
|
11
|
+
findOrCreateUser(regional: string, utnUsername: string, email?: string): Promise<number>;
|
|
12
|
+
findUser(userId: number): Promise<StoredUser | null>;
|
|
13
|
+
findUserByEmail(email: string): Promise<StoredUser | null>;
|
|
14
|
+
/** Todos los usuarios, los más nuevos primero. */
|
|
15
|
+
listUsers(): Promise<ListedUser[]>;
|
|
16
|
+
/**
|
|
17
|
+
* Emite un token para el usuario y devuelve el valor en claro: se muestra una sola vez, en la base
|
|
18
|
+
* queda solo el hash. Sin `ttlSeconds` no vence (el token fijo del modo local).
|
|
19
|
+
*/
|
|
20
|
+
issueToken(userId: number, options: {
|
|
21
|
+
kind: TokenKind;
|
|
22
|
+
clientId?: string;
|
|
23
|
+
ttlSeconds?: number;
|
|
24
|
+
}): Promise<string>;
|
|
25
|
+
/** El token si es de ese tipo y está vigente; si no, null. */
|
|
26
|
+
findToken(token: string, kind: TokenKind): Promise<IssuedToken | null>;
|
|
27
|
+
revokeToken(token: string): Promise<void>;
|
|
28
|
+
/** La sesión del usuario en una plataforma, tal como la guardó: cada plataforma decide su forma. */
|
|
29
|
+
loadSession(userId: number, platform: string): Promise<string | null>;
|
|
30
|
+
saveSession(userId: number, platform: string, data: string): Promise<void>;
|
|
31
|
+
/** Cuándo se guardó por última vez la sesión, en segundos desde epoch; null si no hay. */
|
|
32
|
+
sessionUpdatedAt(userId: number, platform: string): Promise<number | null>;
|
|
33
|
+
/** Las regionales que alguien ya usó en este servidor sin que estuvieran verificadas en el catálogo. */
|
|
34
|
+
listVerifiedRegionals(): Promise<string[]>;
|
|
35
|
+
/** true si esta llamada la verificó: nadie lo había hecho antes. */
|
|
36
|
+
markRegionalVerified(regional: string): Promise<boolean>;
|
|
37
|
+
/** Clientes OAuth registrados por registro dinámico (RFC 7591). */
|
|
38
|
+
saveClient(client: RegisteredClient): Promise<void>;
|
|
39
|
+
findClient(clientId: string): Promise<RegisteredClient | null>;
|
|
40
|
+
/** Los clientes que todavía tienen un token vigente del usuario: las apps que tiene conectadas. */
|
|
41
|
+
listConnectedClients(userId: number): Promise<ConnectedClient[]>;
|
|
42
|
+
/** Borra todos los tokens del usuario para ese cliente: la app queda desconectada. */
|
|
43
|
+
revokeClient(userId: number, clientId: string): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export interface ConnectedClient {
|
|
46
|
+
clientId: string;
|
|
47
|
+
/** El que mandó la app al registrarse; null para el token fijo de `utn-mcp login --http`. */
|
|
48
|
+
clientName: string | null;
|
|
49
|
+
/** Cuándo se emitió su token más nuevo, en segundos desde epoch: los refresh rotan con el uso. */
|
|
50
|
+
lastTokenAt: number;
|
|
51
|
+
}
|
|
52
|
+
export interface StoredUser {
|
|
53
|
+
id: number;
|
|
54
|
+
/** El id de su regional (`frba`, `frt`...): su aula y, junto con el nombre, su identidad. */
|
|
55
|
+
regional: string;
|
|
56
|
+
utnUsername: string;
|
|
57
|
+
/** El institucional, el que se usa para decidir el acceso; null en usuarios de antes de guardarlo. */
|
|
58
|
+
email: string | null;
|
|
59
|
+
}
|
|
60
|
+
export interface ListedUser extends StoredUser {
|
|
61
|
+
/** Cuándo entró por primera vez, en segundos desde epoch. */
|
|
62
|
+
createdAt: number;
|
|
63
|
+
}
|
|
64
|
+
/** Un cliente público: no tiene secreto, se autentica con PKCE. */
|
|
65
|
+
export interface RegisteredClient {
|
|
66
|
+
clientId: string;
|
|
67
|
+
clientName: string | null;
|
|
68
|
+
redirectUris: string[];
|
|
69
|
+
}
|
|
70
|
+
/** `access` va en cada request al MCP; `refresh` solo sirve para pedir un `access` nuevo. */
|
|
71
|
+
export type TokenKind = "access" | "refresh";
|
|
72
|
+
export interface IssuedToken {
|
|
73
|
+
userId: number;
|
|
74
|
+
clientId: string | null;
|
|
75
|
+
/** En segundos desde epoch; null si no vence. */
|
|
76
|
+
expiresAt: number | null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ConnectedClient, IssuedToken, ListedUser, RegisteredClient, SessionStore, StoredUser, TokenKind } from "./session-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* El SessionStore sobre SQLite. Las sesiones van cifradas con AES-256-GCM: tienen el token de Moodle,
|
|
4
|
+
* que alcanza para entrar al aula como el usuario. Los tokens se guardan
|
|
5
|
+
* hasheados, así que una copia de la base no sirve para usar el servidor.
|
|
6
|
+
*/
|
|
7
|
+
export declare class SqliteStore implements SessionStore {
|
|
8
|
+
private readonly db;
|
|
9
|
+
private readonly key;
|
|
10
|
+
private constructor();
|
|
11
|
+
/** `node:sqlite` llega con Node 22.13; se importa acá para que el modo STDIO siga andando en Node 20. */
|
|
12
|
+
static open(path?: string): Promise<SqliteStore>;
|
|
13
|
+
findOrCreateUser(regional: string, utnUsername: string, email?: string): Promise<number>;
|
|
14
|
+
findUserByEmail(email: string): Promise<StoredUser | null>;
|
|
15
|
+
listUsers(): Promise<ListedUser[]>;
|
|
16
|
+
findUser(userId: number): Promise<StoredUser | null>;
|
|
17
|
+
issueToken(userId: number, options: {
|
|
18
|
+
kind: TokenKind;
|
|
19
|
+
clientId?: string;
|
|
20
|
+
ttlSeconds?: number;
|
|
21
|
+
}): Promise<string>;
|
|
22
|
+
findToken(token: string, kind: TokenKind): Promise<IssuedToken | null>;
|
|
23
|
+
revokeToken(token: string): Promise<void>;
|
|
24
|
+
loadSession(userId: number, platform: string): Promise<string | null>;
|
|
25
|
+
saveSession(userId: number, platform: string, data: string): Promise<void>;
|
|
26
|
+
listVerifiedRegionals(): Promise<string[]>;
|
|
27
|
+
markRegionalVerified(regional: string): Promise<boolean>;
|
|
28
|
+
saveClient(client: RegisteredClient): Promise<void>;
|
|
29
|
+
findClient(clientId: string): Promise<RegisteredClient | null>;
|
|
30
|
+
sessionUpdatedAt(userId: number, platform: string): Promise<number | null>;
|
|
31
|
+
listConnectedClients(userId: number): Promise<ConnectedClient[]>;
|
|
32
|
+
revokeClient(userId: number, clientId: string): Promise<void>;
|
|
33
|
+
/** iv + tag + texto cifrado, en un solo blob. */
|
|
34
|
+
private encrypt;
|
|
35
|
+
private decrypt;
|
|
36
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface UploadedFile {
|
|
2
|
+
name: string;
|
|
3
|
+
bytes: Uint8Array;
|
|
4
|
+
}
|
|
5
|
+
/** Lo que acepta una subida; lo pone la plataforma que la abre. */
|
|
6
|
+
export interface UploadLimits {
|
|
7
|
+
maxFiles?: number;
|
|
8
|
+
/** Por archivo. */
|
|
9
|
+
maxBytes: number;
|
|
10
|
+
/** En minúscula y con punto (".pdf"); sin lista, cualquiera. */
|
|
11
|
+
extensions?: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* De dónde saca el servidor los archivos que el usuario quiere subir a una plataforma: en STDIO, del
|
|
15
|
+
* disco, que es el suyo; en HTTP, de una subida, porque el servidor puede estar en otra máquina.
|
|
16
|
+
* `owner` es el usuario de la request: una subida solo la puede usar quien la abrió.
|
|
17
|
+
*/
|
|
18
|
+
export type FileSource = {
|
|
19
|
+
local: true;
|
|
20
|
+
} | {
|
|
21
|
+
local: false;
|
|
22
|
+
inbox: UploadInbox;
|
|
23
|
+
owner: string;
|
|
24
|
+
};
|
|
25
|
+
interface Upload {
|
|
26
|
+
owner: string;
|
|
27
|
+
title: string;
|
|
28
|
+
limits: UploadLimits;
|
|
29
|
+
/** null mientras no se subió nada. */
|
|
30
|
+
files: UploadedFile[] | null;
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
}
|
|
33
|
+
/** Tope de una subida entera, en bytes: los archivos quedan en memoria hasta que se entregan. */
|
|
34
|
+
export declare const MAX_UPLOAD_BYTES: number;
|
|
35
|
+
/**
|
|
36
|
+
* Links de un solo uso para recibir archivos por HTTP: los sube el agente con `curl` si tiene
|
|
37
|
+
* terminal, o el usuario desde el navegador. Los archivos quedan en memoria hasta que una tool los
|
|
38
|
+
* toma o el link vence; si el servidor se reinicia, se pierden y hay que pedir otro link.
|
|
39
|
+
*/
|
|
40
|
+
export declare class UploadInbox {
|
|
41
|
+
private readonly baseUrl;
|
|
42
|
+
private readonly uploads;
|
|
43
|
+
constructor(baseUrl: string);
|
|
44
|
+
open(owner: string, title: string, limits: UploadLimits): {
|
|
45
|
+
id: string;
|
|
46
|
+
url: string;
|
|
47
|
+
expiresAt: string;
|
|
48
|
+
};
|
|
49
|
+
/** La subida para mostrar su formulario, o undefined si no existe o venció. */
|
|
50
|
+
get(id: string): Pick<Upload, "title" | "limits" | "files"> | undefined;
|
|
51
|
+
/** Guarda los archivos de una subida; cada link acepta una sola. */
|
|
52
|
+
receive(id: string, files: UploadedFile[]): void;
|
|
53
|
+
/** Los archivos que se subieron, para entregarlos. No los borra: si la entrega falla, se reintenta. */
|
|
54
|
+
take(id: string, owner: string): UploadedFile[];
|
|
55
|
+
close(id: string): void;
|
|
56
|
+
private sweep;
|
|
57
|
+
}
|
|
58
|
+
/** Cantidad, tamaño, tipo y nombres repetidos, que Moodle no acepta. */
|
|
59
|
+
export declare function checkFiles(files: UploadedFile[], limits: UploadLimits): void;
|
|
60
|
+
/** Tamaño para personas: en KB por debajo del mega, para que un archivo chico no diga "0.0 MB". */
|
|
61
|
+
export declare const formatSize: (bytes: number) => string;
|
|
62
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "utn-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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
|
},
|
|
@@ -54,5 +55,12 @@
|
|
|
54
55
|
},
|
|
55
56
|
"engines": {
|
|
56
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
|
+
}
|
|
57
65
|
}
|
|
58
66
|
}
|