utn-mcp 0.8.3 → 0.9.1
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 +30 -3
- package/build/aula-virtual/client/client.d.ts +97 -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/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/fields.d.ts +11 -0
- package/build/aula-virtual/format/files.d.ts +32 -0
- 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/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/autogestion/client/catalog.d.ts +20 -0
- package/build/autogestion/client/catalog.js +22 -0
- package/build/autogestion/client/connector.d.ts +71 -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 +19 -0
- package/build/autogestion/client/install.js +91 -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 +18 -0
- package/build/autogestion/client/session.js +1 -0
- package/build/autogestion/client/stored-session.d.ts +42 -0
- package/build/autogestion/client/stored-session.js +126 -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/link-page.d.ts +7 -0
- package/build/autogestion/link-page.js +62 -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 +26 -0
- package/build/http.js +7 -2
- package/build/index.d.ts +2 -0
- package/build/index.js +35 -4
- package/build/oauth/access.d.ts +18 -0
- package/build/oauth/admin.d.ts +8 -0
- package/build/oauth/admin.js +1 -1
- 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/oauth/server.js +1 -1
- package/build/server.d.ts +11 -0
- package/build/server.js +19 -2
- package/build/shared/documents/index.d.ts +33 -0
- package/build/shared/documents/merge.d.ts +32 -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/expiring.d.ts +26 -0
- package/build/{oauth → shared}/expiring.js +4 -3
- 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/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 +8 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ask, askChoice, askHidden } from "../shared/input.js";
|
|
2
|
+
import { logger } from "../shared/logger.js";
|
|
3
|
+
import { catalogEntryForRegional } from "./client/catalog.js";
|
|
4
|
+
import { connectorKit, systemForRegional, UnsupportedRegionalError } from "./client/connector.js";
|
|
5
|
+
import { saveAutogestionCredentials } from "./client/credentials.js";
|
|
6
|
+
import { installConnector, installedManifest } from "./client/install.js";
|
|
7
|
+
import { loadInstalledSystems } from "./client/installed-systems.js";
|
|
8
|
+
/**
|
|
9
|
+
* `utn-mcp login autogestion`: instala el conector de la regional si falta o quedó viejo, pide legajo
|
|
10
|
+
* y contraseña, los prueba contra el sistema y recién ahí los guarda en el keychain. La misma regional
|
|
11
|
+
* que se usa para el aula decide qué sistema de autogestión toca.
|
|
12
|
+
*/
|
|
13
|
+
export async function runAutogestionLogin(regionalId) {
|
|
14
|
+
const entry = catalogEntryForRegional(regionalId);
|
|
15
|
+
if (entry && (await installedManifest(entry))?.version !== entry.version) {
|
|
16
|
+
logger.info(`Bajando el conector de ${entry.name}...`);
|
|
17
|
+
await installConnector(entry);
|
|
18
|
+
}
|
|
19
|
+
const system = systemForRegional(regionalId, await loadInstalledSystems());
|
|
20
|
+
if (!system)
|
|
21
|
+
throw new UnsupportedRegionalError(regionalId);
|
|
22
|
+
if (entry?.regionales[regionalId] === "candidate") {
|
|
23
|
+
logger.info(`El conector de ${system.name} todavía no se probó en tu regional: si algo no anda, contalo en ` +
|
|
24
|
+
"https://github.com/nialvape/utn-mcp/issues.");
|
|
25
|
+
}
|
|
26
|
+
const hint = system.usernameHint?.(regionalId);
|
|
27
|
+
const username = (await ask(`Legajo o usuario de ${system.name}${hint ? ` (${hint})` : ""}: `)).trim();
|
|
28
|
+
if (!username)
|
|
29
|
+
throw new Error("Falta el usuario.");
|
|
30
|
+
const password = await askHidden("Contraseña: ");
|
|
31
|
+
// Se guardan recién después de comprobar que el sistema las aceptó.
|
|
32
|
+
const connector = system.create(regionalId, connectorKit);
|
|
33
|
+
await connector.login({ username, password });
|
|
34
|
+
if (await saveAutogestionCredentials(system.id, { username, password })) {
|
|
35
|
+
logger.info("Credenciales guardadas en el keychain del sistema.");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
logger.warn("No hay keychain disponible: definí las variables de entorno del sistema (ver README).");
|
|
39
|
+
}
|
|
40
|
+
logger.info(`Listo: ${system.name} conectado.`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Después del login del aula: si la regional tiene un conector en el catálogo, ofrece conectarlo.
|
|
44
|
+
* Un error acá no deshace el login del aula, que ya quedó guardado.
|
|
45
|
+
*/
|
|
46
|
+
export async function offerAutogestionLogin(regionalId) {
|
|
47
|
+
const entry = catalogEntryForRegional(regionalId);
|
|
48
|
+
if (!entry)
|
|
49
|
+
return;
|
|
50
|
+
const options = ["Sí", "No, después con `utn-mcp login autogestion`"];
|
|
51
|
+
if ((await askChoice(`¿Conectar también ${entry.name} (estado académico, correlativas, finales)?`, options)) !== 0)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
await runAutogestionLogin(regionalId);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
logger.warn(`${e.message}\nEl aula quedó conectada; ${entry.name} se puede conectar después con \`utn-mcp login autogestion\`.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { getCorrelatividades } from "./format/correlatividades.js";
|
|
2
|
+
import { getEstadoAcademico } from "./format/estado-academico.js";
|
|
3
|
+
import { getExamenes } from "./format/examenes.js";
|
|
4
|
+
import { getCursandoAhora } from "./format/notas-parciales.js";
|
|
5
|
+
// JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
|
|
6
|
+
const json = (data) => ({
|
|
7
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
8
|
+
});
|
|
9
|
+
const failure = (e) => ({
|
|
10
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
11
|
+
isError: true,
|
|
12
|
+
});
|
|
13
|
+
/** Igual para todas: el login es distinto en local (CLI) y en HTTP (link), así que lo explica el error. */
|
|
14
|
+
const LOGIN_NOTE = " Si todavía no entró a su autogestión, el error dice cómo hacerlo; seguilo. Las credenciales son las del " +
|
|
15
|
+
"sistema de autogestión, que no siempre son las del aula virtual.";
|
|
16
|
+
export function registerAutogestionTools(server, session) {
|
|
17
|
+
server.registerTool("autogestion_estado_academico", {
|
|
18
|
+
description: "Estado académico del usuario según la autogestión de su regional: cada materia OBLIGATORIA " +
|
|
19
|
+
"del plan con su estado (regular, aprobada, libre), plan, nota y fecha. No es el catálogo " +
|
|
20
|
+
"completo de la carrera: puede incluir electivas aprobadas y no listar materias sin " +
|
|
21
|
+
"movimientos cargados. Las que se están cursando se marcan como Cursando cuando el " +
|
|
22
|
+
"sistema permite saberlas." + LOGIN_NOTE,
|
|
23
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
24
|
+
}, async () => {
|
|
25
|
+
try {
|
|
26
|
+
return json(await getEstadoAcademico(session));
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return failure(e);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
server.registerTool("autogestion_cursando_ahora", {
|
|
33
|
+
description: "Materias que el usuario está cursando ahora según la autogestión de su regional, con su " +
|
|
34
|
+
"comisión, aula y horarios cuando el sistema los publica. Incluye electivas, a diferencia " +
|
|
35
|
+
"del estado académico." + LOGIN_NOTE,
|
|
36
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
37
|
+
}, async () => {
|
|
38
|
+
try {
|
|
39
|
+
return json(await getCursandoAhora(session));
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return failure(e);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
server.registerTool("autogestion_examenes", {
|
|
46
|
+
description: "Historial de exámenes finales del usuario según la autogestión de su regional: fecha, " +
|
|
47
|
+
"materia, nota o estado del examen cuando todavía no hay nota, plan y código. Puede cubrir " +
|
|
48
|
+
"también materias rendidas en planes anteriores y electivas que el estado académico no " +
|
|
49
|
+
"muestra." + LOGIN_NOTE,
|
|
50
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
51
|
+
}, async () => {
|
|
52
|
+
try {
|
|
53
|
+
return json(await getExamenes(session));
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
return failure(e);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
server.registerTool("autogestion_correlatividades", {
|
|
60
|
+
description: "Correlatividades de la carrera del usuario según la autogestión de su regional, separadas " +
|
|
61
|
+
"para cursar y para rendir cuando el sistema las distingue: para cada materia, qué exige " +
|
|
62
|
+
"tener regular o aprobada. Es la fuente dinámica de " +
|
|
63
|
+
"correlatividades: usala en lugar de suponer datos de un plan fijo." + LOGIN_NOTE,
|
|
64
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
65
|
+
}, async () => {
|
|
66
|
+
try {
|
|
67
|
+
return json(await getCorrelatividades(session));
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return failure(e);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
package/build/http.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type AuthInfo, type McpServerFactory } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { StoredAutogestion } from "./autogestion/client/stored-session.js";
|
|
3
|
+
import type { AccessPolicy } from "./oauth/access.js";
|
|
4
|
+
import type { RedirectAllowlist } from "./oauth/redirects.js";
|
|
5
|
+
import type { SessionStore } from "./shared/store/session-store.js";
|
|
6
|
+
import { type UploadInbox } from "./shared/uploads.js";
|
|
7
|
+
/** El usuario del store dueño del token, en `extra.userId`. */
|
|
8
|
+
export declare function userIdOf(authInfo: AuthInfo | undefined): number;
|
|
9
|
+
/** Dónde escucha el servidor y, si está publicado, desde qué dirección lo ven los clientes. */
|
|
10
|
+
export interface HttpOptions {
|
|
11
|
+
host: string;
|
|
12
|
+
port: number;
|
|
13
|
+
/** Sin barra final, por ejemplo `https://mcp.tu-dominio.com`. */
|
|
14
|
+
publicUrl?: string;
|
|
15
|
+
/** Quién puede conectarse por OAuth. */
|
|
16
|
+
access: AccessPolicy;
|
|
17
|
+
/** A dónde puede volver el navegador después del login de OAuth. */
|
|
18
|
+
redirects: RedirectAllowlist;
|
|
19
|
+
/** Con él se habilita la API de /admin (ver `mountAdmin`). */
|
|
20
|
+
adminSecret?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión.
|
|
24
|
+
* `autogestion` sirve la página donde cada usuario entra a la autogestión de su regional.
|
|
25
|
+
*/
|
|
26
|
+
export declare function startHttpServer(factory: McpServerFactory, options: HttpOptions, store: SessionStore, inbox: UploadInbox, autogestion: StoredAutogestion): void;
|
package/build/http.js
CHANGED
|
@@ -2,6 +2,7 @@ import express from "express";
|
|
|
2
2
|
import { Readable } from "node:stream";
|
|
3
3
|
import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, OAuthErrorCode, verifyBearerToken, } from "@modelcontextprotocol/server";
|
|
4
4
|
import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node";
|
|
5
|
+
import { mountAutogestionLink } from "./autogestion/link-page.js";
|
|
5
6
|
import { mountAdmin } from "./oauth/admin.js";
|
|
6
7
|
import { cors, mountOAuth } from "./oauth/server.js";
|
|
7
8
|
import { logger } from "./shared/logger.js";
|
|
@@ -41,8 +42,11 @@ function storeVerifier(store) {
|
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
|
|
44
|
-
/**
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión.
|
|
47
|
+
* `autogestion` sirve la página donde cada usuario entra a la autogestión de su regional.
|
|
48
|
+
*/
|
|
49
|
+
export function startHttpServer(factory, options, store, inbox, autogestion) {
|
|
46
50
|
const { host, port, publicUrl, access, redirects, adminSecret } = options;
|
|
47
51
|
const handler = createMcpHandler(factory, { onerror: (e) => logger.error(e.message) });
|
|
48
52
|
const verifier = storeVerifier(store);
|
|
@@ -99,6 +103,7 @@ export function startHttpServer(factory, options, store, inbox) {
|
|
|
99
103
|
res.type("html").send(page(upload.title, uploadForm(upload.limits)));
|
|
100
104
|
});
|
|
101
105
|
app.post("/subir/:id", sameOrigin, (req, res) => receiveUpload(inbox, req, res));
|
|
106
|
+
mountAutogestionLink(app, autogestion, sameOrigin);
|
|
102
107
|
app.listen(port, host, () => {
|
|
103
108
|
logger.info(`Servidor MCP escuchando en http://${host}:${port}/mcp`);
|
|
104
109
|
if (publicUrl)
|
package/build/index.d.ts
ADDED
package/build/index.js
CHANGED
|
@@ -6,6 +6,11 @@ import { LocalSession } from "./aula-virtual/client/local-session.js";
|
|
|
6
6
|
import { defaultRegional } from "./aula-virtual/client/regionals.js";
|
|
7
7
|
import { StoredAulaSession } from "./aula-virtual/client/stored-session.js";
|
|
8
8
|
import { runLogin } from "./aula-virtual/login.js";
|
|
9
|
+
import { installCatalog } from "./autogestion/client/install.js";
|
|
10
|
+
import { loadInstalledSystems } from "./autogestion/client/installed-systems.js";
|
|
11
|
+
import { LocalAutogestionSession } from "./autogestion/client/local-session.js";
|
|
12
|
+
import { StoredAutogestion } from "./autogestion/client/stored-session.js";
|
|
13
|
+
import { offerAutogestionLogin, runAutogestionLogin } from "./autogestion/login.js";
|
|
9
14
|
import { startHttpServer, userIdOf } from "./http.js";
|
|
10
15
|
import { accessPolicyFromEnv } from "./oauth/access.js";
|
|
11
16
|
import { redirectAllowlistFromEnv } from "./oauth/redirects.js";
|
|
@@ -18,6 +23,7 @@ const USAGE = `Uso:
|
|
|
18
23
|
utn-mcp --http [--host H] [--port N] servidor MCP por HTTP (por defecto 127.0.0.1:3000)
|
|
19
24
|
utn-mcp login guardar credenciales de la UTN y obtener el primer token
|
|
20
25
|
utn-mcp login --http lo mismo, para el modo HTTP: guarda la sesión y emite el token del cliente
|
|
26
|
+
utn-mcp login autogestion instalar el conector de la autogestión de tu regional y guardar legajo y contraseña
|
|
21
27
|
|
|
22
28
|
Si hay un .env en la carpeta actual, se lee al arrancar (ver .env.example).`;
|
|
23
29
|
async function main() {
|
|
@@ -38,7 +44,15 @@ async function main() {
|
|
|
38
44
|
// Una UTN_REGIONAL que no existe corta acá: si no, recién fallaría al primer login o al primer pedido.
|
|
39
45
|
defaultRegional();
|
|
40
46
|
if (positionals[0] === "login") {
|
|
41
|
-
|
|
47
|
+
if (positionals[1] === "autogestion") {
|
|
48
|
+
await runAutogestionLogin((await new LocalSession().getRegional()).id);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
await runLogin(values.http);
|
|
52
|
+
// En HTTP cada usuario entra a su autogestión desde el link que le pasa el agente.
|
|
53
|
+
if (!values.http)
|
|
54
|
+
await offerAutogestionLogin((await new LocalSession().getRegional()).id);
|
|
55
|
+
}
|
|
42
56
|
return;
|
|
43
57
|
}
|
|
44
58
|
if (values.http) {
|
|
@@ -51,10 +65,13 @@ async function main() {
|
|
|
51
65
|
const loginUrl = process.env.UTN_MCP_LOGIN_URL || `${baseUrl}/login`;
|
|
52
66
|
const store = await SqliteStore.open();
|
|
53
67
|
const inbox = new UploadInbox(baseUrl);
|
|
68
|
+
// Atiende a usuarios de cualquier regional: se instalan todos los conectores del catálogo.
|
|
69
|
+
await installCatalog();
|
|
70
|
+
const autogestion = new StoredAutogestion(baseUrl, store, await loadSystems());
|
|
54
71
|
const factory = (ctx) => {
|
|
55
72
|
const userId = userIdOf(ctx.authInfo);
|
|
56
73
|
const aula = new StoredAulaSession(store, userId, loginUrl);
|
|
57
|
-
return createServer(aula, { local: false, inbox, owner: String(userId) });
|
|
74
|
+
return createServer(aula, { local: false, inbox, owner: String(userId) }, autogestion.forUser(userId));
|
|
58
75
|
};
|
|
59
76
|
const options = {
|
|
60
77
|
host: values.host,
|
|
@@ -64,15 +81,29 @@ async function main() {
|
|
|
64
81
|
redirects: redirectAllowlistFromEnv(),
|
|
65
82
|
adminSecret: process.env.UTN_MCP_ADMIN_SECRET || undefined,
|
|
66
83
|
};
|
|
67
|
-
startHttpServer(factory, options, store, inbox);
|
|
84
|
+
startHttpServer(factory, options, store, inbox, autogestion);
|
|
68
85
|
}
|
|
69
86
|
else {
|
|
70
87
|
// STDIO: un solo usuario, el de la compu, con la misma sesión para todas las conexiones.
|
|
71
88
|
const aula = new LocalSession();
|
|
72
|
-
|
|
89
|
+
const autogestion = {
|
|
90
|
+
systems: await loadSystems(),
|
|
91
|
+
sessionFor: (system, regionalId) => new LocalAutogestionSession(system, regionalId),
|
|
92
|
+
};
|
|
93
|
+
serveStdio(() => createServer(aula, { local: true }, autogestion), { onerror: (e) => logger.error(e.message) });
|
|
73
94
|
logger.info("Servidor MCP iniciado en STDIO");
|
|
74
95
|
}
|
|
75
96
|
}
|
|
97
|
+
/** Los conectores de autogestión instalados. Uno roto no puede dejar sin aula virtual: si falla, se sigue sin ellos. */
|
|
98
|
+
async function loadSystems() {
|
|
99
|
+
try {
|
|
100
|
+
return await loadInstalledSystems();
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
logger.warn(e.message);
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
76
107
|
main().catch((e) => {
|
|
77
108
|
logger.error(e.message);
|
|
78
109
|
process.exit(1);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type AccessDecision = {
|
|
2
|
+
allowed: true;
|
|
3
|
+
} | {
|
|
4
|
+
allowed: false;
|
|
5
|
+
reason: string;
|
|
6
|
+
url?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Quién puede usar el servidor: decide con el usuario que acaba de entrar al aula de su regional. */
|
|
9
|
+
export type AccessPolicy = (user: {
|
|
10
|
+
regional: string;
|
|
11
|
+
username: string;
|
|
12
|
+
email: string | null;
|
|
13
|
+
}) => Promise<AccessDecision>;
|
|
14
|
+
/**
|
|
15
|
+
* La política sale del entorno: con UTN_MCP_ACCESS_URL decide otro servicio;
|
|
16
|
+
* con UTN_MCP_ALLOWED_EMAILS, una lista; sin ninguna, entra cualquiera con usuario de la UTN.
|
|
17
|
+
*/
|
|
18
|
+
export declare function accessPolicyFromEnv(env?: NodeJS.ProcessEnv): AccessPolicy;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Express } from "express";
|
|
2
|
+
import type { SessionStore } from "../shared/store/session-store.js";
|
|
3
|
+
/**
|
|
4
|
+
* API para que otro servicio del que hostea (un panel de cuenta, por ejemplo) liste usuarios, muestre y
|
|
5
|
+
* desconecte sus apps, consulte las regionales y dé de alta cuentas vinculando su aula. Solo
|
|
6
|
+
* existe con UTN_MCP_ADMIN_SECRET, que va como `Authorization: Bearer`. Los usuarios se buscan por email.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mountAdmin(app: Express, store: SessionStore, secret: string): void;
|
package/build/oauth/admin.js
CHANGED
|
@@ -5,7 +5,7 @@ import { availableRegionals } from "../aula-virtual/client/regionals.js";
|
|
|
5
5
|
import { InvalidCredentialsError, MfaError } from "../aula-virtual/client/sso-login.js";
|
|
6
6
|
import { AULA_PLATFORM } from "../aula-virtual/client/stored-session.js";
|
|
7
7
|
import { logger } from "../shared/logger.js";
|
|
8
|
-
import { Expiring, RateLimiter } from "
|
|
8
|
+
import { Expiring, RateLimiter } from "../shared/expiring.js";
|
|
9
9
|
import { PendingLogin } from "./pending-login.js";
|
|
10
10
|
/** Intentos de `/admin/verificar` por cuenta del aula cada 15 minutos: que no sirva para probar contraseñas. */
|
|
11
11
|
const VERIFY_ATTEMPTS = 5;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MfaPrompt, SecondFactor } from "../aula-virtual/client/sso-login.js";
|
|
2
|
+
/**
|
|
3
|
+
* Un login que queda esperando el segundo factor entre un request y otro: corre en segundo plano y,
|
|
4
|
+
* cuando le pregunta algo al usuario, `next` lo devuelve para mostrárselo en una página; `answer` le
|
|
5
|
+
* pasa lo que el usuario contestó en el request siguiente.
|
|
6
|
+
*/
|
|
7
|
+
export declare class PendingLogin<T> {
|
|
8
|
+
private readonly run;
|
|
9
|
+
private waiting;
|
|
10
|
+
private listeners;
|
|
11
|
+
constructor(start: (secondFactor: SecondFactor) => Promise<T>);
|
|
12
|
+
/** Espera a que el login termine o le pregunte algo al usuario. */
|
|
13
|
+
next(): Promise<{
|
|
14
|
+
done: T;
|
|
15
|
+
} | {
|
|
16
|
+
prompt: MfaPrompt;
|
|
17
|
+
}>;
|
|
18
|
+
/** Contesta lo que está preguntando; si no pregunta nada (un formulario mandado dos veces), no hace nada. */
|
|
19
|
+
answer(value: string): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** A dónde puede volver el navegador después del login. */
|
|
2
|
+
export interface RedirectAllowlist {
|
|
3
|
+
/** Hostnames a los que se vuelve por HTTPS sin aviso. */
|
|
4
|
+
hosts: Set<string>;
|
|
5
|
+
/** Esquemas de apps instaladas, con los dos puntos (`cursor:`). */
|
|
6
|
+
schemes: Set<string>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Los conocidos más los de UTN_MCP_REDIRECT_ALLOW, separados por coma: hosts (`app.ejemplo.com`)
|
|
10
|
+
* o esquemas terminados en dos puntos (`miapp:`).
|
|
11
|
+
*/
|
|
12
|
+
export declare function redirectAllowlistFromEnv(env?: NodeJS.ProcessEnv): RedirectAllowlist;
|
|
13
|
+
/**
|
|
14
|
+
* `trusted`: loopback, un host HTTPS de la lista o el esquema de una app de la lista.
|
|
15
|
+
* `unknown`: cualquier otro host HTTPS, como el de un agente propio; se acepta con aviso.
|
|
16
|
+
* `rejected`: el resto.
|
|
17
|
+
*/
|
|
18
|
+
export declare function redirectTrust(uri: unknown, allow: RedirectAllowlist): "trusted" | "unknown" | "rejected";
|
|
19
|
+
/** Cómo se le nombra al usuario el destino. */
|
|
20
|
+
export declare function describeRedirect(uri: string): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Express, type RequestHandler } from "express";
|
|
2
|
+
import type { SessionStore } from "../shared/store/session-store.js";
|
|
3
|
+
import type { AccessPolicy } from "./access.js";
|
|
4
|
+
import { type RedirectAllowlist } from "./redirects.js";
|
|
5
|
+
export interface OAuthOptions {
|
|
6
|
+
/** URL base del servidor, sin barra final: es el issuer y de ahí salen los endpoints. */
|
|
7
|
+
issuer: string;
|
|
8
|
+
store: SessionStore;
|
|
9
|
+
access: AccessPolicy;
|
|
10
|
+
/** Rechaza los POST de formularios que vengan de otro origen. */
|
|
11
|
+
sameOrigin: RequestHandler;
|
|
12
|
+
/** A dónde puede volver el navegador con el code. */
|
|
13
|
+
redirects: RedirectAllowlist;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Servidor de autorización OAuth 2.1 para los clientes MCP: registro dinámico, PKCE obligatorio y
|
|
17
|
+
* clientes públicos. El usuario se identifica con la UTN, y ese mismo login vincula su aula: la
|
|
18
|
+
* contraseña se usa una vez y no se guarda. Devuelve la URL de la metadata del recurso, que va en el 401.
|
|
19
|
+
*/
|
|
20
|
+
export declare function mountOAuth(app: Express, { issuer, store, access, sameOrigin, redirects }: OAuthOptions): string;
|
|
21
|
+
/** Los endpoints que llaman los clientes desde el navegador (como el MCP Inspector) aceptan cualquier origen. */
|
|
22
|
+
export declare const cors: RequestHandler;
|
package/build/oauth/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import { availableRegionals, defaultRegional } from "../aula-virtual/client/regi
|
|
|
6
6
|
import { InvalidCredentialsError, MfaError } from "../aula-virtual/client/sso-login.js";
|
|
7
7
|
import { logger } from "../shared/logger.js";
|
|
8
8
|
import { escapeHtml, page } from "../shared/pages.js";
|
|
9
|
-
import { Expiring, RateLimiter } from "
|
|
9
|
+
import { Expiring, RateLimiter } from "../shared/expiring.js";
|
|
10
10
|
import { PendingLogin } from "./pending-login.js";
|
|
11
11
|
import { describeRedirect, redirectTrust } from "./redirects.js";
|
|
12
12
|
/** Duración del access token, en segundos: una baja de acceso se aplica a más tardar en este tiempo. */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { AulaSession } from "./aula-virtual/client/session.js";
|
|
3
|
+
import type { AutogestionAccess } from "./autogestion/client/session.js";
|
|
4
|
+
import type { FileSource } from "./shared/uploads.js";
|
|
5
|
+
/**
|
|
6
|
+
* Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
|
|
7
|
+
* `files`: de dónde salen los archivos que el usuario sube a una plataforma.
|
|
8
|
+
* `autogestion`: los conectores cargados y la sesión del usuario; sus tools aparecen solo si uno cubre su regional.
|
|
9
|
+
* Es async porque resuelve la regional del usuario antes de registrar la autogestión.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createServer(aula: AulaSession, files: FileSource, autogestion?: AutogestionAccess): Promise<McpServer>;
|
package/build/server.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
|
|
3
|
+
import { systemForRegional } from "./autogestion/client/connector.js";
|
|
4
|
+
import { registerAutogestionTools } from "./autogestion/tools.js";
|
|
5
|
+
import { logger } from "./shared/logger.js";
|
|
3
6
|
/**
|
|
4
7
|
* Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
|
|
5
8
|
* `files`: de dónde salen los archivos que el usuario sube a una plataforma.
|
|
9
|
+
* `autogestion`: los conectores cargados y la sesión del usuario; sus tools aparecen solo si uno cubre su regional.
|
|
10
|
+
* Es async porque resuelve la regional del usuario antes de registrar la autogestión.
|
|
6
11
|
*/
|
|
7
|
-
export function createServer(aula, files) {
|
|
12
|
+
export async function createServer(aula, files, autogestion) {
|
|
8
13
|
const server = new McpServer({
|
|
9
14
|
name: "UTN-MCP",
|
|
10
|
-
version: "0.
|
|
15
|
+
version: "0.9.1",
|
|
11
16
|
});
|
|
12
17
|
registerAulaVirtualTools(server, aula, files);
|
|
18
|
+
if (autogestion?.systems.length) {
|
|
19
|
+
// Un conector roto no puede dejar sin aula virtual: si falla, seguimos sin las tools de autogestión.
|
|
20
|
+
try {
|
|
21
|
+
const regional = await aula.getRegional();
|
|
22
|
+
const system = systemForRegional(regional.id, autogestion.systems);
|
|
23
|
+
if (system)
|
|
24
|
+
registerAutogestionTools(server, autogestion.sessionFor(system, regional.id));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
logger.warn(e.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
13
30
|
return server;
|
|
14
31
|
}
|
|
@@ -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,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,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valores que viven en memoria hasta que se usan o vencen, con un id aleatorio que sirve de secreto: los
|
|
3
|
+
* pedidos de `/authorize` mientras el usuario completa el login, los codes del OAuth hasta que el cliente
|
|
4
|
+
* los canjea o los links para entrar a la autogestión. Si el servidor se reinicia, se pierden y hay que
|
|
5
|
+
* volver a empezar, que es lo que pasaría igual con uno vencido.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Expiring<T> {
|
|
8
|
+
private readonly ttlMs;
|
|
9
|
+
private readonly items;
|
|
10
|
+
/** `ttlMs` en milisegundos. */
|
|
11
|
+
constructor(ttlMs: number);
|
|
12
|
+
add(value: T): string;
|
|
13
|
+
get(id: string): T | undefined;
|
|
14
|
+
/** Lo devuelve y lo borra: un code se canjea una sola vez. */
|
|
15
|
+
take(id: string): T | undefined;
|
|
16
|
+
private sweep;
|
|
17
|
+
}
|
|
18
|
+
/** Cuenta intentos por clave (la IP) en una ventana fija. */
|
|
19
|
+
export declare class RateLimiter {
|
|
20
|
+
private readonly max;
|
|
21
|
+
private readonly windowMs;
|
|
22
|
+
private readonly hits;
|
|
23
|
+
/** `windowMs` en milisegundos. */
|
|
24
|
+
constructor(max: number, windowMs: number);
|
|
25
|
+
allow(key: string): boolean;
|
|
26
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
/**
|
|
3
|
-
* Valores que viven en memoria hasta que se usan o vencen
|
|
4
|
-
* usuario completa el login
|
|
5
|
-
*
|
|
3
|
+
* Valores que viven en memoria hasta que se usan o vencen, con un id aleatorio que sirve de secreto: los
|
|
4
|
+
* pedidos de `/authorize` mientras el usuario completa el login, los codes del OAuth hasta que el cliente
|
|
5
|
+
* los canjea o los links para entrar a la autogestión. Si el servidor se reinicia, se pierden y hay que
|
|
6
|
+
* volver a empezar, que es lo que pasaría igual con uno vencido.
|
|
6
7
|
*/
|
|
7
8
|
export class Expiring {
|
|
8
9
|
ttlMs;
|
|
@@ -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 {};
|