utn-mcp 0.9.0 → 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 +9 -7
- package/build/autogestion/client/catalog.js +3 -3
- package/build/autogestion/client/connector.d.ts +10 -0
- package/build/autogestion/client/install.d.ts +6 -1
- package/build/autogestion/client/install.js +19 -0
- package/build/autogestion/client/session.d.ts +9 -5
- package/build/autogestion/client/stored-session.d.ts +42 -0
- package/build/autogestion/client/stored-session.js +126 -0
- package/build/autogestion/link-page.d.ts +7 -0
- package/build/autogestion/link-page.js +62 -0
- package/build/autogestion/tools.js +7 -7
- package/build/http.d.ts +6 -2
- package/build/http.js +7 -2
- package/build/index.js +25 -4
- package/build/oauth/admin.js +1 -1
- package/build/oauth/server.js +1 -1
- package/build/server.d.ts +4 -2
- package/build/server.js +7 -9
- package/build/{oauth → shared}/expiring.d.ts +4 -3
- package/build/{oauth → shared}/expiring.js +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,10 +29,10 @@ El agente recorre el aula como lo harías vos: entra al curso, mira los foros y
|
|
|
29
29
|
| `aula_choice_answer` | Responde una elección o te anota en un grupo. |
|
|
30
30
|
| `aula_send_message` | Manda un mensaje privado a un docente o compañero. |
|
|
31
31
|
| `aula_calendar_add_event` | Agrega un recordatorio a tu calendario del aula. |
|
|
32
|
-
| `autogestion_cursando_ahora` | Las materias que estás cursando ahora según la autogestión, con comisión, aula y horarios cuando el sistema los publica. Incluye electivas.
|
|
33
|
-
| `autogestion_estado_academico` | Tu estado académico según la autogestión de tu regional: cada materia con su estado, plan, nota y fecha.
|
|
34
|
-
| `autogestion_correlatividades` | Las correlatividades de tu carrera según la autogestión, para cursar y para rendir, en vez de datos fijos del repo.
|
|
35
|
-
| `autogestion_examenes` | Tu historial de finales según la autogestión: fecha, materia, nota o estado, plan y código.
|
|
32
|
+
| `autogestion_cursando_ahora` | Las materias que estás cursando ahora según la autogestión, con comisión, aula y horarios cuando el sistema los publica. Incluye electivas. Aparece solo si tu regional tiene un conector de autogestión. |
|
|
33
|
+
| `autogestion_estado_academico` | Tu estado académico según la autogestión de tu regional: cada materia con su estado, plan, nota y fecha. Aparece solo si tu regional tiene un conector de autogestión. |
|
|
34
|
+
| `autogestion_correlatividades` | Las correlatividades de tu carrera según la autogestión, para cursar y para rendir, en vez de datos fijos del repo. Aparece solo si tu regional tiene un conector de autogestión. |
|
|
35
|
+
| `autogestion_examenes` | Tu historial de finales según la autogestión: fecha, materia, nota o estado, plan y código. Aparece solo si tu regional tiene un conector de autogestión. |
|
|
36
36
|
|
|
37
37
|
Al abrir una tarea, `aula_open` muestra si la entregaste, cuándo vence y la devolución con la nota. Las tools que escriben le piden al agente que te muestre lo que va a hacer y espere tu confirmación.
|
|
38
38
|
|
|
@@ -143,7 +143,7 @@ Si tu regional usa el Sysacad clásico, al terminar `login` te pregunta si quer
|
|
|
143
143
|
```bash
|
|
144
144
|
npx -y utn-mcp login autogestion
|
|
145
145
|
```
|
|
146
|
-
Con Sysacad conectado aparecen las tools `autogestion_*`: tu estado académico, lo que estás cursando, tus finales y las correlatividades de tu carrera. Por ahora son solo de lectura
|
|
146
|
+
Con Sysacad conectado aparecen las tools `autogestion_*`: tu estado académico, lo que estás cursando, tus finales y las correlatividades de tu carrera. Por ahora son solo de lectura.
|
|
147
147
|
|
|
148
148
|
Lo usan Bahía Blanca, Chubut, Concordia, Delta, General Pacheco, Haedo, La Rioja, Mar del Plata, Neuquén, Paraná, Reconquista, Santa Fe, San Francisco, San Nicolás, San Rafael, Tucumán, Tierra del Fuego y Venado Tuerto. En Tucumán anduvo con una versión anterior del conector; esta todavía no se probó con una cuenta real: si lo probás, contanos en un [issue](https://github.com/nialvape/utn-mcp/issues) si funcionó. En algunas el usuario no es el legajo tal cual (en Delta va sin el "23-", por ejemplo); el login te dice qué escribir. Avellaneda y Rosario quedan afuera porque su Sysacad pide un captcha o un desafío del navegador, y Rafaela, Santa Cruz y Trenque Lauquen porque no tienen HTTPS válido y la contraseña viajaría expuesta.
|
|
149
149
|
|
|
@@ -163,6 +163,8 @@ En este modo el servidor no lee archivos de tu disco: para entregar, el agente p
|
|
|
163
163
|
### Hostearlo para otros
|
|
164
164
|
En modo HTTP el servidor también es servidor de autorización OAuth: un cliente MCP (Claude, ChatGPT) que se conecta a `https://tu-dominio/mcp` recibe un 401, descubre el OAuth y abre una página donde el alumno elige su regional y entra con su usuario de la UTN. Cada alumno usa el aula de su regional, aunque sean de regionales distintas. Ese login vincula su aula: la contraseña se usa una sola vez para sacar el token de Moodle y **no se guarda**. Cuando la sesión del aula vence, el agente le pasa el link de `/login` para renovarla, sin reconectar el cliente.
|
|
165
165
|
|
|
166
|
+
La autogestión (Sysacad) funciona igual para cada alumno, según su regional. Al arrancar, el servidor instala los conectores del catálogo en `connectors/` de su carpeta de datos. La primera vez que el agente la necesita, le pasa al alumno un link a `/autogestion/...` para que entre con su legajo y contraseña de Sysacad desde el navegador: la contraseña nunca pasa por el chat. Como con el aula, la contraseña se usa una sola vez y **no se guarda**: queda cifrada en la base solo la sesión de Sysacad. Cuando Sysacad la vence (unos minutos sin usarla), el agente le vuelve a pasar el link.
|
|
167
|
+
|
|
166
168
|
Quién puede entrar lo decidís vos: con `UTN_MCP_ALLOWED_EMAILS` (una lista), o con `UTN_MCP_ACCESS_URL`, que apunta a un servicio tuyo. El servidor le hace `POST` con `{ "email", "usuario", "regional" }` y el header `Authorization: Bearer <UTN_MCP_ACCESS_SECRET>`, y espera `{ "permitido": true }` o `{ "permitido": false, "motivo": "...", "url": "..." }`. Se consulta al conectar y en cada renovación del token (cada hora), así que una baja se aplica sola.
|
|
167
169
|
|
|
168
170
|
La configuración va en variables de entorno o en un `.env` en la carpeta desde donde lo corrés (copiá [`.env.example`](https://github.com/nialvape/utn-mcp/blob/main/.env.example)):
|
|
@@ -193,11 +195,11 @@ Instalado en tu computadora, todo queda ahí:
|
|
|
193
195
|
- **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
|
|
194
196
|
- **Token del aula virtual**: en `%APPDATA%\utn-mcp\session.json` (Windows) o `~/.config/utn-mcp/session.json` (Linux y macOS).
|
|
195
197
|
- **Conector de autogestión**: en `connectors/`, dentro de esa misma carpeta. El legajo y la contraseña de Sysacad van al gestor de credenciales, aparte de los del aula.
|
|
196
|
-
- **En modo HTTP**: la contraseña no se guarda; la sesión va cifrada en `utn-mcp.db`, en la misma carpeta, con la clave de `store.key` (o la de `UTN_MCP_KEY`). Del token del cliente solo se guarda el hash.
|
|
198
|
+
- **En modo HTTP**: la contraseña no se guarda; la sesión va cifrada en `utn-mcp.db`, en la misma carpeta, con la clave de `store.key` (o la de `UTN_MCP_KEY`). Del token del cliente solo se guarda el hash. Con la de Sysacad pasa lo mismo: no se guarda, y su sesión va cifrada en la misma base.
|
|
197
199
|
|
|
198
200
|
En Linux sin gestor de credenciales (servidores, Docker, WSL) podés definir las variables de entorno `UTN_USER` y `UTN_PASS` en lugar de usar `login`. Para Sysacad son `SYSACAD_ASP_USER` y `SYSACAD_ASP_PASS`.
|
|
199
201
|
|
|
200
|
-
Si usás un servidor hosteado por otra persona,
|
|
202
|
+
Si usás un servidor hosteado por otra persona, tus sesiones del aula y de Sysacad las guarda ese servidor (cifradas, y sin tus contraseñas): revisá su política de privacidad.
|
|
201
203
|
|
|
202
204
|
## Desarrollo
|
|
203
205
|
```bash
|
|
@@ -9,9 +9,9 @@ export const CATALOG = [
|
|
|
9
9
|
id: "sysacad-asp",
|
|
10
10
|
name: "Sysacad",
|
|
11
11
|
package: "utn-mcp-sysacad-asp",
|
|
12
|
-
version: "0.
|
|
13
|
-
tarball: `${RELEASES}/sysacad-asp-v0.
|
|
14
|
-
integrity: "sha512-
|
|
12
|
+
version: "0.2.0",
|
|
13
|
+
tarball: `${RELEASES}/sysacad-asp-v0.2.0/utn-mcp-sysacad-asp-0.2.0.tgz`,
|
|
14
|
+
integrity: "sha512-p4j2fTpE3ZqSvcDQ3Rr6L32dOGNFut0GzyHBVpTpg8mqXrx4Un6CqkiJZG+eznQy+Risko+pcnPhWm+GIJ8haw==",
|
|
15
15
|
// FRT anduvo con cuenta real (oct 2026) con la versión anterior, que posteaba directo a `menuAlumno.asp`;
|
|
16
16
|
// esta lee el formulario y todavía no entró nadie con ella.
|
|
17
17
|
regionales: Object.fromEntries(SYSACAD_ASP_REGIONALS.map((id) => [id, "candidate"])),
|
|
@@ -19,6 +19,16 @@ export interface AutogestionConnector {
|
|
|
19
19
|
notasParciales?(): Promise<NotasParcialesMateria[]>;
|
|
20
20
|
/** Historial de exámenes finales, si el sistema lo publica; no todos los conectores lo tienen. */
|
|
21
21
|
examenes?(): Promise<ExamenMateria[]>;
|
|
22
|
+
/**
|
|
23
|
+
* La sesión del sistema sin la contraseña (cookies, ids de sesión), para guardarla cifrada y retomarla
|
|
24
|
+
* en otro request. Sin esto, el conector sirve solo en local: el modo HTTP no guarda contraseñas.
|
|
25
|
+
*/
|
|
26
|
+
exportSession?(): string;
|
|
27
|
+
/**
|
|
28
|
+
* Retoma una sesión de `exportSession`. Sin la contraseña no puede volver a entrar solo: si la sesión
|
|
29
|
+
* venció, las llamadas rechazan con `LoginRequiredError`.
|
|
30
|
+
*/
|
|
31
|
+
restoreSession?(data: string): void;
|
|
22
32
|
}
|
|
23
33
|
/** Credenciales de un sistema de autogestión. En Sysacad `username` es el legajo. */
|
|
24
34
|
export interface AutogestionCredentials {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CatalogEntry } from "./catalog.js";
|
|
2
2
|
/** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
|
|
3
3
|
export declare const connectorsDir: () => string;
|
|
4
4
|
export declare const connectorDir: (entry: CatalogEntry) => string;
|
|
@@ -7,6 +7,11 @@ export declare function installedManifest(entry: CatalogEntry): Promise<{
|
|
|
7
7
|
version?: string;
|
|
8
8
|
main?: string;
|
|
9
9
|
} | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Para el modo HTTP, que atiende usuarios de cualquier regional: instala los conectores del catálogo que
|
|
12
|
+
* falten o hayan quedado viejos. Uno que no se puede bajar no frena el servidor: queda sin esas tools.
|
|
13
|
+
*/
|
|
14
|
+
export declare function installCatalog(): Promise<void>;
|
|
10
15
|
/**
|
|
11
16
|
* Baja el `.tgz` del catálogo, comprueba su hash y lo extrae en su carpeta. Sin `npm install`: los
|
|
12
17
|
* conectores no tienen dependencias, así que alcanza con desempaquetarlos, y no hace falta npm en la máquina.
|
|
@@ -3,7 +3,9 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { gunzipSync } from "node:zlib";
|
|
5
5
|
import { HttpError } from "../../shared/http.js";
|
|
6
|
+
import { logger } from "../../shared/logger.js";
|
|
6
7
|
import { configDir } from "../../shared/paths.js";
|
|
8
|
+
import { CATALOG } from "./catalog.js";
|
|
7
9
|
/** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
|
|
8
10
|
export const connectorsDir = () => join(configDir(), "connectors");
|
|
9
11
|
export const connectorDir = (entry) => join(connectorsDir(), entry.package);
|
|
@@ -16,6 +18,23 @@ export async function installedManifest(entry) {
|
|
|
16
18
|
return null;
|
|
17
19
|
}
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Para el modo HTTP, que atiende usuarios de cualquier regional: instala los conectores del catálogo que
|
|
23
|
+
* falten o hayan quedado viejos. Uno que no se puede bajar no frena el servidor: queda sin esas tools.
|
|
24
|
+
*/
|
|
25
|
+
export async function installCatalog() {
|
|
26
|
+
for (const entry of CATALOG) {
|
|
27
|
+
if ((await installedManifest(entry))?.version === entry.version)
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
await installConnector(entry);
|
|
31
|
+
logger.info(`Conector de ${entry.name} ${entry.version} instalado.`);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
logger.warn(`No se pudo instalar el conector de ${entry.name}: ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
19
38
|
/** Bloques del formato tar: encabezado y datos van de a 512 bytes. */
|
|
20
39
|
const TAR_BLOCK = 512;
|
|
21
40
|
/**
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import type { AutogestionConnector } from "./connector.js";
|
|
1
|
+
import type { AutogestionConnector, AutogestionSystem } from "./connector.js";
|
|
2
2
|
/**
|
|
3
3
|
* La autogestión del usuario. Todo lo que habla con el sistema recibe esto y no sabe de dónde sale:
|
|
4
|
-
* `LocalAutogestionSession` (STDIO, el usuario de la compu
|
|
5
|
-
*
|
|
4
|
+
* `LocalAutogestionSession` (STDIO, el usuario de la compu, credenciales del keychain) o la hosteada
|
|
5
|
+
* (HTTP, el usuario entra desde un link y la contraseña no se guarda).
|
|
6
6
|
*/
|
|
7
7
|
export interface AutogestionSession {
|
|
8
8
|
/**
|
|
9
9
|
* El conector del sistema que usa la regional del usuario, logueado y listo.
|
|
10
|
-
* Rechaza con
|
|
11
|
-
* si el sistema las rechaza.
|
|
10
|
+
* Rechaza con un error que dice cómo iniciar sesión si todavía no se puede.
|
|
12
11
|
*/
|
|
13
12
|
getConnector(): Promise<AutogestionConnector>;
|
|
14
13
|
}
|
|
14
|
+
/** Los conectores disponibles y cómo armar la sesión de un usuario: lo que recibe `createServer`. */
|
|
15
|
+
export interface AutogestionAccess {
|
|
16
|
+
systems: AutogestionSystem[];
|
|
17
|
+
sessionFor(system: AutogestionSystem, regionalId: string): AutogestionSession;
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SessionStore } from "../../shared/store/session-store.js";
|
|
2
|
+
import { type AutogestionConnector, type AutogestionCredentials, type AutogestionSystem } from "./connector.js";
|
|
3
|
+
import type { AutogestionAccess } from "./session.js";
|
|
4
|
+
/** Con qué nombre guarda la autogestión su sesión en el store. */
|
|
5
|
+
export declare const AUTOGESTION_PLATFORM = "autogestion";
|
|
6
|
+
/** Un link abierto para que el usuario entre a su autogestión desde el navegador. */
|
|
7
|
+
export interface PendingLink {
|
|
8
|
+
userId: number;
|
|
9
|
+
regionalId: string;
|
|
10
|
+
system: AutogestionSystem;
|
|
11
|
+
}
|
|
12
|
+
/** Falta entrar a la autogestión: el mensaje trae el link para el usuario. */
|
|
13
|
+
export declare class AutogestionLinkRequiredError extends Error {
|
|
14
|
+
constructor(systemName: string, url: string, reason?: string);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* La autogestión del modo HTTP, para todos los usuarios del servidor. Igual que el aula: la contraseña se
|
|
18
|
+
* usa una sola vez, en la página del link, y no se guarda; en el store queda cifrada la sesión del
|
|
19
|
+
* sistema (cookies), que el conector exporta. Cuando el sistema la vence, el usuario vuelve a entrar.
|
|
20
|
+
*/
|
|
21
|
+
export declare class StoredAutogestion {
|
|
22
|
+
private readonly baseUrl;
|
|
23
|
+
private readonly store;
|
|
24
|
+
/** Los links viven en memoria: si el servidor se reinicia, el agente pide otro. */
|
|
25
|
+
private readonly links;
|
|
26
|
+
/** Solo los conectores que pueden exportar su sesión: los otros necesitarían guardar la contraseña. */
|
|
27
|
+
readonly systems: AutogestionSystem[];
|
|
28
|
+
constructor(baseUrl: string, store: SessionStore, systems: AutogestionSystem[]);
|
|
29
|
+
/** Lo que recibe `createServer` para un usuario. */
|
|
30
|
+
forUser(userId: number): AutogestionAccess;
|
|
31
|
+
/** El conector del usuario con su sesión guardada, o null si no hay una de ese sistema. */
|
|
32
|
+
restore(userId: number, system: AutogestionSystem, regionalId: string): Promise<AutogestionConnector | null>;
|
|
33
|
+
/** Guarda la sesión del conector (cifrada, como todo el store); las cookies pueden cambiar en cada pedido. */
|
|
34
|
+
save(userId: number, connector: AutogestionConnector): Promise<void>;
|
|
35
|
+
/** El store no borra sesiones: una vacía es "no hay". */
|
|
36
|
+
forget(userId: number): Promise<void>;
|
|
37
|
+
/** Un link para que el usuario entre; los que se abrieron antes siguen valiendo hasta que vencen o se usa uno. */
|
|
38
|
+
openLink(userId: number, system: AutogestionSystem, regionalId: string): string;
|
|
39
|
+
link(id: string): PendingLink | undefined;
|
|
40
|
+
/** Entra con lo que el usuario escribió en la página del link y guarda la sesión; la contraseña se descarta. */
|
|
41
|
+
complete(id: string, credentials: AutogestionCredentials): Promise<PendingLink>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Expiring } from "../../shared/expiring.js";
|
|
2
|
+
import { connectorKit, InvalidCredentialsError, LoginRequiredError, } from "./connector.js";
|
|
3
|
+
/** Con qué nombre guarda la autogestión su sesión en el store. */
|
|
4
|
+
export const AUTOGESTION_PLATFORM = "autogestion";
|
|
5
|
+
/** Cuánto dura un link para entrar, en milisegundos. */
|
|
6
|
+
const LINK_TTL_MS = 30 * 60 * 1000;
|
|
7
|
+
/** Falta entrar a la autogestión: el mensaje trae el link para el usuario. */
|
|
8
|
+
export class AutogestionLinkRequiredError extends Error {
|
|
9
|
+
constructor(systemName, url, reason) {
|
|
10
|
+
super(`${reason ? `${reason} ` : ""}Falta iniciar sesión en ${systemName}. Pasale al usuario este link para que entre ` +
|
|
11
|
+
`con su legajo y contraseña de ${systemName} (vence en 30 minutos): ${url} . Cuando te avise que entró, ` +
|
|
12
|
+
"reintentá. La contraseña no pasa por el chat: no se la pidas.");
|
|
13
|
+
this.name = "AutogestionLinkRequiredError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* La autogestión del modo HTTP, para todos los usuarios del servidor. Igual que el aula: la contraseña se
|
|
18
|
+
* usa una sola vez, en la página del link, y no se guarda; en el store queda cifrada la sesión del
|
|
19
|
+
* sistema (cookies), que el conector exporta. Cuando el sistema la vence, el usuario vuelve a entrar.
|
|
20
|
+
*/
|
|
21
|
+
export class StoredAutogestion {
|
|
22
|
+
baseUrl;
|
|
23
|
+
store;
|
|
24
|
+
/** Los links viven en memoria: si el servidor se reinicia, el agente pide otro. */
|
|
25
|
+
links = new Expiring(LINK_TTL_MS);
|
|
26
|
+
/** Solo los conectores que pueden exportar su sesión: los otros necesitarían guardar la contraseña. */
|
|
27
|
+
systems;
|
|
28
|
+
constructor(baseUrl, store, systems) {
|
|
29
|
+
this.baseUrl = baseUrl;
|
|
30
|
+
this.store = store;
|
|
31
|
+
this.systems = systems.filter((system) => {
|
|
32
|
+
const connector = system.create(system.regionales[0], connectorKit);
|
|
33
|
+
return typeof connector.exportSession === "function" && typeof connector.restoreSession === "function";
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Lo que recibe `createServer` para un usuario. */
|
|
37
|
+
forUser(userId) {
|
|
38
|
+
return {
|
|
39
|
+
systems: this.systems,
|
|
40
|
+
sessionFor: (system, regionalId) => new StoredAutogestionSession(this, userId, system, regionalId),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** El conector del usuario con su sesión guardada, o null si no hay una de ese sistema. */
|
|
44
|
+
async restore(userId, system, regionalId) {
|
|
45
|
+
const raw = await this.store.loadSession(userId, AUTOGESTION_PLATFORM);
|
|
46
|
+
if (!raw)
|
|
47
|
+
return null;
|
|
48
|
+
const data = JSON.parse(raw);
|
|
49
|
+
if (data.systemId !== system.id)
|
|
50
|
+
return null;
|
|
51
|
+
const connector = system.create(regionalId, connectorKit);
|
|
52
|
+
connector.restoreSession(data.session);
|
|
53
|
+
return connector;
|
|
54
|
+
}
|
|
55
|
+
/** Guarda la sesión del conector (cifrada, como todo el store); las cookies pueden cambiar en cada pedido. */
|
|
56
|
+
async save(userId, connector) {
|
|
57
|
+
const data = { systemId: connector.systemId, session: connector.exportSession() };
|
|
58
|
+
await this.store.saveSession(userId, AUTOGESTION_PLATFORM, JSON.stringify(data));
|
|
59
|
+
}
|
|
60
|
+
/** El store no borra sesiones: una vacía es "no hay". */
|
|
61
|
+
async forget(userId) {
|
|
62
|
+
await this.store.saveSession(userId, AUTOGESTION_PLATFORM, "");
|
|
63
|
+
}
|
|
64
|
+
/** Un link para que el usuario entre; los que se abrieron antes siguen valiendo hasta que vencen o se usa uno. */
|
|
65
|
+
openLink(userId, system, regionalId) {
|
|
66
|
+
return `${this.baseUrl}/autogestion/${this.links.add({ userId, regionalId, system })}`;
|
|
67
|
+
}
|
|
68
|
+
link(id) {
|
|
69
|
+
return this.links.get(id);
|
|
70
|
+
}
|
|
71
|
+
/** Entra con lo que el usuario escribió en la página del link y guarda la sesión; la contraseña se descarta. */
|
|
72
|
+
async complete(id, credentials) {
|
|
73
|
+
const link = this.link(id);
|
|
74
|
+
if (!link)
|
|
75
|
+
throw new Error("El link venció o ya se usó. Pedile al agente uno nuevo.");
|
|
76
|
+
const connector = link.system.create(link.regionalId, connectorKit);
|
|
77
|
+
await connector.login(credentials);
|
|
78
|
+
await this.save(link.userId, connector);
|
|
79
|
+
this.links.take(id);
|
|
80
|
+
return link;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** La sesión de un usuario en un request: el conector con la sesión guardada o, si no hay, el link para entrar. */
|
|
84
|
+
class StoredAutogestionSession {
|
|
85
|
+
stored;
|
|
86
|
+
userId;
|
|
87
|
+
system;
|
|
88
|
+
regionalId;
|
|
89
|
+
constructor(stored, userId, system, regionalId) {
|
|
90
|
+
this.stored = stored;
|
|
91
|
+
this.userId = userId;
|
|
92
|
+
this.system = system;
|
|
93
|
+
this.regionalId = regionalId;
|
|
94
|
+
}
|
|
95
|
+
async getConnector() {
|
|
96
|
+
const connector = await this.stored.restore(this.userId, this.system, this.regionalId);
|
|
97
|
+
if (!connector)
|
|
98
|
+
throw this.linkRequired();
|
|
99
|
+
// Después de cada pedido se guarda la sesión, por si el sistema rotó las cookies. Si la venció, se
|
|
100
|
+
// descarta y se pide entrar de nuevo: los mensajes del conector hablan del login por CLI, que acá no existe.
|
|
101
|
+
const guard = async (call) => {
|
|
102
|
+
try {
|
|
103
|
+
const result = await call();
|
|
104
|
+
await this.stored.save(this.userId, connector);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (!(e instanceof InvalidCredentialsError || e instanceof LoginRequiredError))
|
|
109
|
+
throw e;
|
|
110
|
+
await this.stored.forget(this.userId);
|
|
111
|
+
throw this.linkRequired(`La sesión de ${this.system.name} venció.`);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
systemId: connector.systemId,
|
|
116
|
+
login: (credentials) => connector.login(credentials),
|
|
117
|
+
estadoAcademico: () => guard(() => connector.estadoAcademico()),
|
|
118
|
+
correlatividades: () => guard(() => connector.correlatividades()),
|
|
119
|
+
notasParciales: connector.notasParciales && (() => guard(() => connector.notasParciales())),
|
|
120
|
+
examenes: connector.examenes && (() => guard(() => connector.examenes())),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
linkRequired(reason) {
|
|
124
|
+
return new AutogestionLinkRequiredError(this.system.name, this.stored.openLink(this.userId, this.system, this.regionalId), reason);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Express, type RequestHandler } from "express";
|
|
2
|
+
import type { StoredAutogestion } from "./client/stored-session.js";
|
|
3
|
+
/**
|
|
4
|
+
* `/autogestion/:id`: la página del link que el agente le pasa al usuario para entrar a su autogestión
|
|
5
|
+
* en modo HTTP. Sin bearer, como `/subir`: el id del link es el secreto y lo abre un navegador.
|
|
6
|
+
*/
|
|
7
|
+
export declare function mountAutogestionLink(app: Express, stored: StoredAutogestion, sameOrigin: RequestHandler): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { RateLimiter } from "../shared/expiring.js";
|
|
3
|
+
import { logger } from "../shared/logger.js";
|
|
4
|
+
import { escapeHtml, page } from "../shared/pages.js";
|
|
5
|
+
import { InvalidCredentialsError } from "./client/connector.js";
|
|
6
|
+
/** Intentos de login por IP cada 15 minutos (en milisegundos): cada uno es un login contra el sistema de la regional. */
|
|
7
|
+
const MAX_ATTEMPTS = 10;
|
|
8
|
+
const ATTEMPTS_WINDOW_MS = 15 * 60 * 1000;
|
|
9
|
+
/**
|
|
10
|
+
* `/autogestion/:id`: la página del link que el agente le pasa al usuario para entrar a su autogestión
|
|
11
|
+
* en modo HTTP. Sin bearer, como `/subir`: el id del link es el secreto y lo abre un navegador.
|
|
12
|
+
*/
|
|
13
|
+
export function mountAutogestionLink(app, stored, sameOrigin) {
|
|
14
|
+
const form = express.urlencoded({ extended: false, limit: "16kb" });
|
|
15
|
+
const limiter = new RateLimiter(MAX_ATTEMPTS, ATTEMPTS_WINDOW_MS);
|
|
16
|
+
app.get("/autogestion/:id", sameOrigin, (req, res) => {
|
|
17
|
+
const link = stored.link(String(req.params.id));
|
|
18
|
+
if (!link)
|
|
19
|
+
return void expired(res);
|
|
20
|
+
res.type("html").send(linkPage(link));
|
|
21
|
+
});
|
|
22
|
+
app.post("/autogestion/:id", sameOrigin, form, async (req, res) => {
|
|
23
|
+
const id = String(req.params.id);
|
|
24
|
+
const link = stored.link(id);
|
|
25
|
+
if (!link)
|
|
26
|
+
return void expired(res);
|
|
27
|
+
const retry = (error) => void res.type("html").send(linkPage(link, error));
|
|
28
|
+
if (!limiter.allow(req.ip ?? "desconocida"))
|
|
29
|
+
return retry("Demasiados intentos. Esperá unos minutos y probá de nuevo.");
|
|
30
|
+
const username = String(req.body?.usuario ?? "").trim();
|
|
31
|
+
const password = String(req.body?.contrasena ?? "");
|
|
32
|
+
if (!username || !password)
|
|
33
|
+
return retry("Completá legajo y contraseña.");
|
|
34
|
+
try {
|
|
35
|
+
await stored.complete(id, { username, password });
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e instanceof InvalidCredentialsError)
|
|
39
|
+
return retry(`${link.system.name} rechazó el legajo o la contraseña.`);
|
|
40
|
+
logger.warn(`Autogestión: ${e.message}`);
|
|
41
|
+
return retry(e.message);
|
|
42
|
+
}
|
|
43
|
+
res.type("html").send(page("Listo", `<p>Entraste a ${escapeHtml(link.system.name)}. Volvé al chat y pedile al agente que reintente.</p>`));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function expired(res) {
|
|
47
|
+
res.status(404).type("html").send(page("Link vencido", "<p>Este link venció o ya se usó. Pedile al agente uno nuevo.</p>"));
|
|
48
|
+
}
|
|
49
|
+
function linkPage(link, error) {
|
|
50
|
+
const name = escapeHtml(link.system.name);
|
|
51
|
+
const hint = link.system.usernameHint?.(link.regionalId);
|
|
52
|
+
return page(`Entrar a ${link.system.name}`, `<p>Tu agente necesita entrar a ${name} para leer tu información académica. Usá el legajo y la contraseña ` +
|
|
53
|
+
`de ${name}, que no siempre son los del aula virtual.</p>` +
|
|
54
|
+
(error ? `<p class=error>${escapeHtml(error)}</p>` : "") +
|
|
55
|
+
'<form method="post">' +
|
|
56
|
+
`<label>Legajo${hint ? ` (${escapeHtml(hint)})` : ""}<input name="usuario" autocomplete="username" autocapitalize="none" required></label>` +
|
|
57
|
+
'<label>Contraseña<input name="contrasena" type="password" autocomplete="current-password" required></label>' +
|
|
58
|
+
'<button type="submit">Entrar</button></form>' +
|
|
59
|
+
"<p class=nota>La contraseña se usa una sola vez para entrar y no se guarda: queda guardada, cifrada, " +
|
|
60
|
+
`solo la sesión de ${name}. Cuando ${name} la vence, te va a pedir que entres de nuevo. ` +
|
|
61
|
+
'El código es abierto: <a href="https://github.com/nialvape/utn-mcp">github.com/nialvape/utn-mcp</a>.</p>', "Autogestión");
|
|
62
|
+
}
|
|
@@ -10,15 +10,16 @@ const failure = (e) => ({
|
|
|
10
10
|
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
11
11
|
isError: true,
|
|
12
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.";
|
|
13
16
|
export function registerAutogestionTools(server, session) {
|
|
14
17
|
server.registerTool("autogestion_estado_academico", {
|
|
15
18
|
description: "Estado académico del usuario según la autogestión de su regional: cada materia OBLIGATORIA " +
|
|
16
19
|
"del plan con su estado (regular, aprobada, libre), plan, nota y fecha. No es el catálogo " +
|
|
17
20
|
"completo de la carrera: puede incluir electivas aprobadas y no listar materias sin " +
|
|
18
21
|
"movimientos cargados. Las que se están cursando se marcan como Cursando cuando el " +
|
|
19
|
-
"sistema permite saberlas.
|
|
20
|
-
"`utn-mcp login autogestion` una vez; las credenciales son las del sistema de autogestión, " +
|
|
21
|
-
"que no siempre son las del aula virtual.",
|
|
22
|
+
"sistema permite saberlas." + LOGIN_NOTE,
|
|
22
23
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
23
24
|
}, async () => {
|
|
24
25
|
try {
|
|
@@ -31,7 +32,7 @@ export function registerAutogestionTools(server, session) {
|
|
|
31
32
|
server.registerTool("autogestion_cursando_ahora", {
|
|
32
33
|
description: "Materias que el usuario está cursando ahora según la autogestión de su regional, con su " +
|
|
33
34
|
"comisión, aula y horarios cuando el sistema los publica. Incluye electivas, a diferencia " +
|
|
34
|
-
"del estado académico.
|
|
35
|
+
"del estado académico." + LOGIN_NOTE,
|
|
35
36
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
36
37
|
}, async () => {
|
|
37
38
|
try {
|
|
@@ -45,7 +46,7 @@ export function registerAutogestionTools(server, session) {
|
|
|
45
46
|
description: "Historial de exámenes finales del usuario según la autogestión de su regional: fecha, " +
|
|
46
47
|
"materia, nota o estado del examen cuando todavía no hay nota, plan y código. Puede cubrir " +
|
|
47
48
|
"también materias rendidas en planes anteriores y electivas que el estado académico no " +
|
|
48
|
-
"muestra.
|
|
49
|
+
"muestra." + LOGIN_NOTE,
|
|
49
50
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
50
51
|
}, async () => {
|
|
51
52
|
try {
|
|
@@ -59,8 +60,7 @@ export function registerAutogestionTools(server, session) {
|
|
|
59
60
|
description: "Correlatividades de la carrera del usuario según la autogestión de su regional, separadas " +
|
|
60
61
|
"para cursar y para rendir cuando el sistema las distingue: para cada materia, qué exige " +
|
|
61
62
|
"tener regular o aprobada. Es la fuente dinámica de " +
|
|
62
|
-
"correlatividades: usala en lugar de suponer datos de un plan fijo.
|
|
63
|
-
"`utn-mcp login autogestion` una vez.",
|
|
63
|
+
"correlatividades: usala en lugar de suponer datos de un plan fijo." + LOGIN_NOTE,
|
|
64
64
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
65
65
|
}, async () => {
|
|
66
66
|
try {
|
package/build/http.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AuthInfo, type McpServerFactory } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { StoredAutogestion } from "./autogestion/client/stored-session.js";
|
|
2
3
|
import type { AccessPolicy } from "./oauth/access.js";
|
|
3
4
|
import type { RedirectAllowlist } from "./oauth/redirects.js";
|
|
4
5
|
import type { SessionStore } from "./shared/store/session-store.js";
|
|
@@ -18,5 +19,8 @@ export interface HttpOptions {
|
|
|
18
19
|
/** Con él se habilita la API de /admin (ver `mountAdmin`). */
|
|
19
20
|
adminSecret?: string;
|
|
20
21
|
}
|
|
21
|
-
/**
|
|
22
|
-
|
|
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.js
CHANGED
|
@@ -6,6 +6,10 @@ 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";
|
|
9
13
|
import { offerAutogestionLogin, runAutogestionLogin } from "./autogestion/login.js";
|
|
10
14
|
import { startHttpServer, userIdOf } from "./http.js";
|
|
11
15
|
import { accessPolicyFromEnv } from "./oauth/access.js";
|
|
@@ -45,7 +49,7 @@ async function main() {
|
|
|
45
49
|
}
|
|
46
50
|
else {
|
|
47
51
|
await runLogin(values.http);
|
|
48
|
-
//
|
|
52
|
+
// En HTTP cada usuario entra a su autogestión desde el link que le pasa el agente.
|
|
49
53
|
if (!values.http)
|
|
50
54
|
await offerAutogestionLogin((await new LocalSession().getRegional()).id);
|
|
51
55
|
}
|
|
@@ -61,10 +65,13 @@ async function main() {
|
|
|
61
65
|
const loginUrl = process.env.UTN_MCP_LOGIN_URL || `${baseUrl}/login`;
|
|
62
66
|
const store = await SqliteStore.open();
|
|
63
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());
|
|
64
71
|
const factory = (ctx) => {
|
|
65
72
|
const userId = userIdOf(ctx.authInfo);
|
|
66
73
|
const aula = new StoredAulaSession(store, userId, loginUrl);
|
|
67
|
-
return createServer(aula, { local: false, inbox, owner: String(userId) });
|
|
74
|
+
return createServer(aula, { local: false, inbox, owner: String(userId) }, autogestion.forUser(userId));
|
|
68
75
|
};
|
|
69
76
|
const options = {
|
|
70
77
|
host: values.host,
|
|
@@ -74,15 +81,29 @@ async function main() {
|
|
|
74
81
|
redirects: redirectAllowlistFromEnv(),
|
|
75
82
|
adminSecret: process.env.UTN_MCP_ADMIN_SECRET || undefined,
|
|
76
83
|
};
|
|
77
|
-
startHttpServer(factory, options, store, inbox);
|
|
84
|
+
startHttpServer(factory, options, store, inbox, autogestion);
|
|
78
85
|
}
|
|
79
86
|
else {
|
|
80
87
|
// STDIO: un solo usuario, el de la compu, con la misma sesión para todas las conexiones.
|
|
81
88
|
const aula = new LocalSession();
|
|
82
|
-
|
|
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) });
|
|
83
94
|
logger.info("Servidor MCP iniciado en STDIO");
|
|
84
95
|
}
|
|
85
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
|
+
}
|
|
86
107
|
main().catch((e) => {
|
|
87
108
|
logger.error(e.message);
|
|
88
109
|
process.exit(1);
|
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;
|
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. */
|
package/build/server.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { AulaSession } from "./aula-virtual/client/session.js";
|
|
3
|
+
import type { AutogestionAccess } from "./autogestion/client/session.js";
|
|
3
4
|
import type { FileSource } from "./shared/uploads.js";
|
|
4
5
|
/**
|
|
5
6
|
* Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
|
|
6
7
|
* `files`: de dónde salen los archivos que el usuario sube a una plataforma.
|
|
7
|
-
*
|
|
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.
|
|
8
10
|
*/
|
|
9
|
-
export declare function createServer(aula: AulaSession, files: FileSource): Promise<McpServer>;
|
|
11
|
+
export declare function createServer(aula: AulaSession, files: FileSource, autogestion?: AutogestionAccess): Promise<McpServer>;
|
package/build/server.js
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
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
3
|
import { systemForRegional } from "./autogestion/client/connector.js";
|
|
6
4
|
import { registerAutogestionTools } from "./autogestion/tools.js";
|
|
7
5
|
import { logger } from "./shared/logger.js";
|
|
8
6
|
/**
|
|
9
7
|
* Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
|
|
10
8
|
* `files`: de dónde salen los archivos que el usuario sube a una plataforma.
|
|
11
|
-
*
|
|
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.
|
|
12
11
|
*/
|
|
13
|
-
export async function createServer(aula, files) {
|
|
12
|
+
export async function createServer(aula, files, autogestion) {
|
|
14
13
|
const server = new McpServer({
|
|
15
14
|
name: "UTN-MCP",
|
|
16
|
-
version: "0.9.
|
|
15
|
+
version: "0.9.1",
|
|
17
16
|
});
|
|
18
17
|
registerAulaVirtualTools(server, aula, files);
|
|
19
|
-
if (
|
|
18
|
+
if (autogestion?.systems.length) {
|
|
20
19
|
// Un conector roto no puede dejar sin aula virtual: si falla, seguimos sin las tools de autogestión.
|
|
21
20
|
try {
|
|
22
21
|
const regional = await aula.getRegional();
|
|
23
|
-
const
|
|
24
|
-
const system = systemForRegional(regional.id, systems);
|
|
22
|
+
const system = systemForRegional(regional.id, autogestion.systems);
|
|
25
23
|
if (system)
|
|
26
|
-
registerAutogestionTools(server,
|
|
24
|
+
registerAutogestionTools(server, autogestion.sessionFor(system, regional.id));
|
|
27
25
|
}
|
|
28
26
|
catch (e) {
|
|
29
27
|
logger.warn(e.message);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Valores que viven en memoria hasta que se usan o vencen
|
|
3
|
-
* usuario completa el login
|
|
4
|
-
*
|
|
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.
|
|
5
6
|
*/
|
|
6
7
|
export declare class Expiring<T> {
|
|
7
8
|
private readonly ttlMs;
|
|
@@ -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;
|
package/package.json
CHANGED