utn-mcp 0.6.7 → 0.6.9

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 CHANGED
@@ -23,6 +23,8 @@ El agente recorre el aula como lo harías vos: entra al curso, mira los foros y
23
23
  | `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
24
24
  | `aula_forum_edit_post` | Edita una publicación tuya. |
25
25
  | `aula_forum_delete_post` | Borra una publicación tuya. |
26
+ | `aula_assign_submit` | Entrega una tarea: sube archivos de tu compu, escribe el texto en línea y confirma el envío. |
27
+ | `aula_assign_upload_link` | Solo en modo HTTP: un link para subir los archivos de una entrega, que usa el agente con `curl` o vos desde el navegador. |
26
28
  | `aula_choice_answer` | Responde una elección o te anota en un grupo. |
27
29
  | `aula_send_message` | Manda un mensaje privado a un docente o compañero. |
28
30
  | `aula_calendar_add_event` | Agrega un recordatorio a tu calendario del aula. |
@@ -118,15 +120,23 @@ utn-mcp login
118
120
  y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
119
121
 
120
122
  ### Modo HTTP (opcional)
123
+ Necesita Node 22.13 o más nuevo. Primero, una vez:
124
+ ```bash
125
+ npx -y utn-mcp login --http
126
+ ```
127
+ Te muestra un token: configurá tu cliente MCP para que mande el header `Authorization: Bearer <token>`. Después:
121
128
  ```bash
122
129
  npx -y utn-mcp --http --port 3000
123
130
  ```
124
131
  Levanta el servidor en `http://127.0.0.1:3000/mcp`, solo accesible desde tu propia máquina. Tenés que dejar la terminal abierta mientras lo uses.
125
132
 
133
+ En este modo el servidor no lee archivos de tu disco: para entregar, el agente pide un link con `aula_assign_upload_link` y sube ahí los archivos con `curl` si tiene terminal, o te pasa el link para que los elijas desde el navegador. Si el servidor se publica detrás de otra dirección, definí `UTN_MCP_PUBLIC_URL` para que los links la usen.
134
+
126
135
  ## Dónde se guardan tus datos
127
136
  Todo queda en tu computadora:
128
137
  - **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
129
138
  - **Token del aula virtual y cookies de sesión**: en `%APPDATA%\utn-mcp\session.json` (Windows) o `~/.config/utn-mcp/session.json` (Linux y macOS).
139
+ - **En modo HTTP**: 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.
130
140
 
131
141
  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`.
132
142
 
@@ -73,6 +73,52 @@ export async function getAssignments(session, courseId) {
73
73
  export function getSubmissionStatus(session, assignid) {
74
74
  return callWs(session, "mod_assign_get_submission_status", { assignid });
75
75
  }
76
+ /** Un borrador vacío: el `itemid` que se manda para dejar un área sin archivos. */
77
+ export async function getUnusedDraftItemId(session) {
78
+ const { itemid } = await callWs(session, "core_files_get_unused_draft_itemid");
79
+ return itemid;
80
+ }
81
+ /** Moodle no tira excepción cuando no guarda o no envía: lo dice en `warnings`. */
82
+ function throwOnWarnings(warnings, fallback) {
83
+ if (warnings?.length)
84
+ throw new MoodleError(warnings.map((w) => w.message).join(" ") || fallback);
85
+ }
86
+ /**
87
+ * Guarda la entrega. Cada plugin habilitado reemplaza lo que tenía con lo que se le pasa: el de
88
+ * archivos toma el borrador `filesItemId` entero y el de texto, `text`.
89
+ */
90
+ export async function saveSubmission(session, assignmentid, data) {
91
+ const params = { assignmentid };
92
+ if (data.filesItemId !== undefined)
93
+ params["plugindata[files_filemanager]"] = data.filesItemId;
94
+ if (data.text) {
95
+ params["plugindata[onlinetext_editor][text]"] = data.text.html;
96
+ params["plugindata[onlinetext_editor][format]"] = data.text.format;
97
+ params["plugindata[onlinetext_editor][itemid]"] = data.text.itemid;
98
+ }
99
+ const warnings = await callWs(session, "mod_assign_save_submission", params);
100
+ throwOnWarnings(warnings, "Moodle no guardó la entrega.");
101
+ }
102
+ /** Confirma el envío de un borrador. Después ya no se puede editar. */
103
+ export async function submitForGrading(session, assignmentid, acceptStatement) {
104
+ const warnings = await callWs(session, "mod_assign_submit_for_grading", {
105
+ assignmentid,
106
+ acceptsubmissionstatement: acceptStatement ? 1 : 0,
107
+ });
108
+ throwOnWarnings(warnings, "Moodle no envió la entrega.");
109
+ }
110
+ /**
111
+ * Vacía la entrega del usuario (o la de su grupo). Es la única forma de dejarla sin nada:
112
+ * mod_assign_save_submission rechaza una entrega vacía.
113
+ */
114
+ export async function removeSubmission(session, assignid) {
115
+ const { userid } = await getSiteInfo(session);
116
+ const warnings = await callWs(session, "mod_assign_remove_submission", {
117
+ userid,
118
+ assignid,
119
+ });
120
+ throwOnWarnings(warnings, "Moodle no quitó la entrega.");
121
+ }
76
122
  // --- Elecciones (choice) y selección de grupos (choicegroup) ---
77
123
  export async function getChoices(session, courseId) {
78
124
  const { choices } = await callWs(session, "mod_choice_get_choices_by_courses", {
@@ -1,6 +1,7 @@
1
1
  import { postForm } from "../../shared/http.js";
2
2
  import { AULA_BASE_URL } from "./constants.js";
3
3
  const WS_URL = `${AULA_BASE_URL}/webservice/rest/server.php`;
4
+ const UPLOAD_URL = `${AULA_BASE_URL}/webservice/upload.php`;
4
5
  const TOKEN_ERRORS = new Set(["invalidtoken", "expiredtoken"]);
5
6
  export class MoodleError extends Error {
6
7
  errorcode;
@@ -60,6 +61,39 @@ export async function fetchFile(session, url, maxBytes) {
60
61
  return { bytes: await readWithLimit(res, maxBytes), contentType };
61
62
  }
62
63
  }
64
+ /**
65
+ * Sube archivos al área de borrador del usuario por `webservice/upload.php` y devuelve su `itemid`,
66
+ * que es lo que piden las funciones que guardan archivos. Todos quedan en el mismo borrador.
67
+ */
68
+ export async function uploadDraftFiles(session, files) {
69
+ let token = await session.getToken();
70
+ for (let attempt = 0;; attempt++) {
71
+ const form = new FormData();
72
+ form.set("token", token);
73
+ form.set("filearea", "draft");
74
+ form.set("itemid", "0");
75
+ files.forEach((f, i) => form.set(`file_${i + 1}`, new Blob([new Uint8Array(f.bytes)]), f.name));
76
+ let res;
77
+ try {
78
+ res = await fetch(UPLOAD_URL, { method: "POST", body: form, signal: AbortSignal.timeout(120_000) });
79
+ }
80
+ catch (e) {
81
+ throw new MoodleError(`No se pudo subir el archivo: ${e.message}`);
82
+ }
83
+ if (!res.ok)
84
+ throw new MoodleError(`HTTP ${res.status} al subir el archivo`);
85
+ // Si sale bien, un registro por archivo; si no, `{ error, errorcode }`.
86
+ const data = (await res.json());
87
+ if (Array.isArray(data) && data[0])
88
+ return data[0].itemid;
89
+ const err = Array.isArray(data) ? {} : data;
90
+ if (attempt === 0 && err.errorcode && TOKEN_ERRORS.has(err.errorcode)) {
91
+ token = await session.refreshToken();
92
+ continue;
93
+ }
94
+ throw new MoodleError(err.error ?? "Moodle no aceptó el archivo.", err.errorcode);
95
+ }
96
+ }
63
97
  /** Lee el cuerpo cortando apenas pasa el límite, sin esperar a bajarlo entero. */
64
98
  async function readWithLimit(res, maxBytes) {
65
99
  const tooBig = `El archivo pesa más de ${Math.round(maxBytes / 1024 / 1024)} MB; es demasiado para leerlo.`;
@@ -1,2 +1 @@
1
- // para version hosteada y multiples usuarios/sesiones
2
1
  export {};
@@ -0,0 +1,55 @@
1
+ import { CookieJar } from "tough-cookie";
2
+ import { loadCredentials } from "./credentials.js";
3
+ import { fetchToken, LoginRequiredError } from "./sso-login.js";
4
+ /** Con qué nombre guarda el aula su sesión en el store. */
5
+ export const AULA_PLATFORM = "aula";
6
+ /**
7
+ * Sesión del modo HTTP: la de un usuario del store. Renueva el token con las cookies del SSO y, si
8
+ * ya no alcanzan, con la contraseña del keychain cuando es de este mismo usuario: eso pasa solo en
9
+ * local, porque el servidor hosteado no guarda contraseñas.
10
+ */
11
+ export class StoredAulaSession {
12
+ store;
13
+ userId;
14
+ refreshing = null;
15
+ constructor(store, userId) {
16
+ this.store = store;
17
+ this.userId = userId;
18
+ }
19
+ async getToken() {
20
+ return (await this.read()).token;
21
+ }
22
+ refreshToken() {
23
+ this.refreshing ??= this.renew().finally(() => {
24
+ this.refreshing = null;
25
+ });
26
+ return this.refreshing;
27
+ }
28
+ async renew() {
29
+ const stored = await this.read();
30
+ const jar = await CookieJar.deserialize(stored.cookies);
31
+ // Las del keychain (o UTN_USER/UTN_PASS) son de una sola persona: con otro usuario
32
+ // entraríamos al aula de ella y guardaríamos su token en la sesión de este.
33
+ const credentials = await loadCredentials();
34
+ const own = credentials?.username.trim().toLowerCase() === stored.username ? credentials : null;
35
+ let token;
36
+ try {
37
+ token = await fetchToken(jar, own);
38
+ }
39
+ catch (e) {
40
+ if (e instanceof LoginRequiredError) {
41
+ throw new LoginRequiredError("La sesión del aula venció: volvé a iniciar sesión (`utn-mcp login --http`).");
42
+ }
43
+ throw e;
44
+ }
45
+ const data = { ...stored, token, cookies: await jar.serialize() };
46
+ await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
47
+ return token;
48
+ }
49
+ async read() {
50
+ const raw = await this.store.loadSession(this.userId, AULA_PLATFORM);
51
+ if (!raw)
52
+ throw new LoginRequiredError("No hay sesión del aula para este usuario: corré `utn-mcp login --http`.");
53
+ return JSON.parse(raw);
54
+ }
55
+ }
@@ -45,6 +45,7 @@ export async function viewSubmission(session, courseId, module) {
45
45
  // Con borradores, lo subido no cuenta hasta confirmar el envío.
46
46
  requiereConfirmarEnvio: assignment?.submissiondrafts === 1 || undefined,
47
47
  requiereDeclaracion: assignment?.requiresubmissionstatement === 1 || undefined,
48
+ declaracion: (assignment?.requiresubmissionstatement === 1 && htmlToText(assignment.submissionstatement)) || undefined,
48
49
  puedeEditar: attempt?.canedit ?? false,
49
50
  puedeEnviar: attempt?.cansubmit || undefined,
50
51
  bloqueada: attempt?.locked || undefined,
@@ -0,0 +1,214 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { basename, isAbsolute } from "node:path";
3
+ import { htmlToText, textToHtml } from "../../shared/html.js";
4
+ import { checkFiles, formatSize, } from "../../shared/uploads.js";
5
+ import { getAssignments, getSubmissionStatus, getUnusedDraftItemId, removeSubmission, saveSubmission, submitForGrading, } from "../client/client.js";
6
+ import { fetchFile, uploadDraftFiles } from "../client/moodle-api.js";
7
+ import { viewSubmission } from "./activities.js";
8
+ import { parseAddress } from "./address.js";
9
+ import { userFacingUrl } from "./files.js";
10
+ import { findModule } from "./navigate.js";
11
+ /** El máximo por archivo que deja subir el aula (`usermaxuploadfilesize` del sitio, 20 MB). */
12
+ const SITE_MAX_BYTES = 20 * 1024 * 1024;
13
+ /** La tarea con su configuración y el intento actual del usuario, o un error que dice por qué no. */
14
+ async function openAssignment(session, rawAddress) {
15
+ const address = parseAddress(rawAddress);
16
+ if (address.level !== "module")
17
+ throw new Error(`"${rawAddress}" no es la dirección de un módulo.`);
18
+ const { module } = await findModule(session, address.courseId, address.cmid);
19
+ if (module.modname !== "assign") {
20
+ throw new Error(`"${rawAddress}" es un módulo de tipo ${module.modname}; solo se entregan tareas (assign).`);
21
+ }
22
+ const [assignments, status] = await Promise.all([
23
+ getAssignments(session, address.courseId),
24
+ getSubmissionStatus(session, module.instance),
25
+ ]);
26
+ const assignment = assignments.find((a) => a.cmid === module.id);
27
+ const attempt = status.lastattempt;
28
+ if (!assignment || !attempt)
29
+ throw new Error(`No se pudo leer la configuración de la tarea ${rawAddress}.`);
30
+ const config = (plugin, name) => assignment.configs.find((c) => c.subtype === "assignsubmission" && c.plugin === plugin && c.name === name)
31
+ ?.value;
32
+ const limits = {
33
+ maxFiles: Number(config("file", "maxfilesubmissions")) || undefined,
34
+ maxBytes: Number(config("file", "maxsubmissionsizebytes")) || SITE_MAX_BYTES,
35
+ extensions: extensions(config("file", "filetypeslist") || ""),
36
+ };
37
+ return {
38
+ address,
39
+ module,
40
+ assignment,
41
+ attempt,
42
+ config,
43
+ limits,
44
+ acceptsFiles: config("file", "enabled") === "1",
45
+ acceptsText: config("onlinetext", "enabled") === "1",
46
+ submission: attempt.teamsubmission ?? attempt.submission,
47
+ };
48
+ }
49
+ const CLOSED = "La tarea no acepta cambios: ya está enviada, venció, está bloqueada o todavía no abre. " +
50
+ "Abrila con aula_open para ver el estado y las fechas.";
51
+ /** Abre un link para recibir los archivos de una entrega en modo HTTP, con los límites de la tarea. */
52
+ export async function createUploadLink(session, rawAddress, inbox, owner) {
53
+ const { module, attempt, acceptsFiles, limits } = await openAssignment(session, rawAddress);
54
+ if (!acceptsFiles)
55
+ throw new Error("Esta tarea no acepta archivos.");
56
+ if (!attempt.canedit)
57
+ throw new Error(CLOSED);
58
+ const { id, url, expiresAt } = inbox.open(owner, `Entrega: ${module.name}`, limits);
59
+ return {
60
+ subida: id,
61
+ url,
62
+ vence: expiresAt,
63
+ acepta: {
64
+ maximo: limits.maxFiles,
65
+ tamanioMaximo: limits.maxBytes,
66
+ extensiones: limits.extensions?.join(", "),
67
+ },
68
+ conTerminal: `curl -F "archivos=@/ruta/al/archivo.pdf" ${url}`,
69
+ };
70
+ }
71
+ /**
72
+ * Deja la entrega con los archivos y el texto pedidos, y la envía si hace falta. Lo que no se pasa
73
+ * se conserva: Moodle reemplaza cada plugin con lo que recibe, así que se le reenvía lo que ya tenía.
74
+ */
75
+ export async function submitAssignment(session, rawAddress, request, source) {
76
+ const { address, module, assignment, attempt, config, limits, acceptsFiles, acceptsText, submission } = await openAssignment(session, rawAddress);
77
+ const usesDrafts = assignment.submissiondrafts === 1;
78
+ const fileChanges = request.files !== undefined || request.upload !== undefined;
79
+ const changes = fileChanges || request.text !== undefined;
80
+ // Todo lo que se puede validar va antes de tocar nada, para no dejar la entrega a medias.
81
+ if (!changes && !(usesDrafts && request.send)) {
82
+ throw new Error("Pasá archivos, `texto` o los dos: no hay nada para entregar.");
83
+ }
84
+ if (changes && !attempt.canedit)
85
+ throw new Error(CLOSED);
86
+ if (fileChanges && !acceptsFiles)
87
+ throw new Error("Esta tarea no acepta archivos.");
88
+ if (request.text !== undefined && !acceptsText)
89
+ throw new Error("Esta tarea no acepta texto en línea.");
90
+ const confirm = usesDrafts && request.send;
91
+ if (confirm && assignment.requiresubmissionstatement === 1 && !request.acceptStatement) {
92
+ throw new Error("Para enviarla hay que aceptar esta declaración: «" +
93
+ htmlToText(assignment.submissionstatement) +
94
+ "». Mostrásela al usuario y, si la acepta, volvé a llamar con `aceptoDeclaracion: true`.");
95
+ }
96
+ if (changes) {
97
+ const files = acceptsFiles ? await collectFiles(session, request, submittedFiles(submission), source) : [];
98
+ checkFiles(files, limits);
99
+ const text = acceptsText
100
+ ? currentOrNewText(submission, request.text, Number(config("onlinetext", "wordlimit")))
101
+ : null;
102
+ if (!files.length && !htmlToText(text?.html)) {
103
+ await removeSubmission(session, assignment.id);
104
+ }
105
+ else {
106
+ await saveSubmission(session, assignment.id, {
107
+ filesItemId: acceptsFiles
108
+ ? files.length
109
+ ? await uploadDraftFiles(session, files)
110
+ : await getUnusedDraftItemId(session)
111
+ : undefined,
112
+ text: text ? { ...text, itemid: await getUnusedDraftItemId(session) } : undefined,
113
+ });
114
+ }
115
+ if (request.upload && !source.local)
116
+ source.inbox.close(request.upload);
117
+ }
118
+ if (confirm)
119
+ await submitForGrading(session, assignment.id, request.acceptStatement);
120
+ const after = await viewSubmission(session, address.courseId, module);
121
+ return {
122
+ direccion: rawAddress,
123
+ estado: after.estado,
124
+ // Con borradores y sin confirmar, lo subido no cuenta todavía.
125
+ aviso: usesDrafts && !request.send && after.estado === "borrador"
126
+ ? "Quedó como borrador: no cuenta como entregada hasta confirmar el envío."
127
+ : undefined,
128
+ archivos: after.archivos?.map(({ nombre, tamanio, url }) => ({ nombre, tamanio, url })),
129
+ texto: after.texto,
130
+ };
131
+ }
132
+ function submittedFiles(submission) {
133
+ return (submission?.plugins ?? [])
134
+ .filter((p) => p.type === "file")
135
+ .flatMap((p) => p.fileareas?.flatMap((a) => a.files ?? []) ?? []);
136
+ }
137
+ /**
138
+ * Junta los bytes de la lista final: los de `files` (o los que ya estaban, si no se pasa) más los de
139
+ * la subida. Los que ya estaban hay que volver a subirlos porque el borrador reemplaza el área entera.
140
+ */
141
+ async function collectFiles(session, request, current, source) {
142
+ const entries = request.files ?? current.map((f) => userFacingUrl(f.fileurl));
143
+ const collected = [];
144
+ for (const entry of entries) {
145
+ const existing = current.find((f) => userFacingUrl(f.fileurl) === entry || f.fileurl === entry);
146
+ collected.push(existing
147
+ ? {
148
+ name: existing.filename,
149
+ bytes: (await fetchFile(session, new URL(existing.fileurl), SITE_MAX_BYTES)).bytes,
150
+ }
151
+ : await readLocal(entry, source));
152
+ }
153
+ if (request.upload !== undefined) {
154
+ if (source.local)
155
+ throw new Error("`subida` es del modo HTTP; acá los archivos van como ruta en `archivos`.");
156
+ collected.push(...source.inbox.take(request.upload, source.owner));
157
+ }
158
+ return collected;
159
+ }
160
+ async function readLocal(path, source) {
161
+ if (/^https?:\/\//i.test(path)) {
162
+ throw new Error(`"${path}" no es un archivo de esta entrega. Las URLs sirven solo para conservar archivos ` +
163
+ "que ya están subidos (las `url` de `entrega.archivos` en aula_open).");
164
+ }
165
+ if (!source.local) {
166
+ throw new Error(`"${path}" no es un archivo de esta entrega, y este servidor no lee archivos de tu dispositivo: ` +
167
+ "los nuevos se suben con aula_assign_upload_link y se pasan con `subida`.");
168
+ }
169
+ if (!isAbsolute(path))
170
+ throw new Error(`Pasá la ruta absoluta del archivo; "${path}" es relativa.`);
171
+ let size;
172
+ try {
173
+ const info = await stat(path);
174
+ if (!info.isFile())
175
+ throw new Error(`"${path}" no es un archivo.`);
176
+ size = info.size;
177
+ }
178
+ catch (e) {
179
+ if (e.code === "ENOENT")
180
+ throw new Error(`No existe el archivo "${path}".`);
181
+ throw e;
182
+ }
183
+ // El límite de la tarea lo chequea checkFiles; esto solo evita cargar en memoria algo que no puede entrar.
184
+ if (size > SITE_MAX_BYTES) {
185
+ throw new Error(`"${basename(path)}" pesa ${formatSize(size)} y el aula acepta hasta ${formatSize(SITE_MAX_BYTES)} por archivo.`);
186
+ }
187
+ return { name: basename(path), bytes: new Uint8Array(await readFile(path)) };
188
+ }
189
+ /**
190
+ * Moodle valida los tipos en el formulario web, no en el web service: si no lo chequeamos, un .docx
191
+ * entra en una tarea que pide PDF. Los grupos de tipos ("document", "image") no se pueden resolver
192
+ * sin la tabla de Moodle, así que con un grupo en la lista no se restringe.
193
+ */
194
+ function extensions(types) {
195
+ const list = types
196
+ .split(/[\s,;]+/)
197
+ .map((t) => t.trim().toLowerCase())
198
+ .filter(Boolean);
199
+ return list.length && list.every((t) => t.startsWith(".")) ? list : undefined;
200
+ }
201
+ /** El texto nuevo en HTML, o el que ya estaba para no borrarlo al guardar solo archivos. */
202
+ function currentOrNewText(submission, text, wordLimit) {
203
+ if (text === undefined) {
204
+ const field = submission?.plugins
205
+ ?.find((p) => p.type === "onlinetext")
206
+ ?.editorfields?.find((e) => e.name === "onlinetext");
207
+ return { html: field?.text ?? "", format: field?.format ?? 1 };
208
+ }
209
+ const words = text.split(/\s+/).filter(Boolean).length;
210
+ if (wordLimit && words > wordLimit) {
211
+ throw new Error(`El texto tiene ${words} palabras y la tarea acepta hasta ${wordLimit}.`);
212
+ }
213
+ return { html: text.trim() ? textToHtml(text) : "", format: 1 };
214
+ }
@@ -1,18 +1,27 @@
1
+ import { CookieJar } from "tough-cookie";
1
2
  import { ask, askHidden } from "../shared/input.js";
2
3
  import { logger } from "../shared/logger.js";
4
+ import { SqliteStore } from "../shared/store/sqlite-store.js";
3
5
  import { saveCredentials } from "./client/credentials.js";
4
6
  import { LocalSession } from "./client/local-session.js";
7
+ import { fetchToken } from "./client/sso-login.js";
8
+ import { AULA_PLATFORM } from "./client/stored-session.js";
5
9
  /**
6
10
  * `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
7
11
  * y obtiene el primer token. Después el servidor renueva solo.
12
+ * Con `--http` guarda la sesión en el store y emite el token para el cliente MCP.
8
13
  */
9
- export async function runLogin() {
10
- const username = await ask("Usuario institucional de la UTN: ");
14
+ export async function runLogin(http) {
15
+ const username = (await ask("Usuario institucional de la UTN: ")).trim();
11
16
  if (!username)
12
17
  throw new Error("Falta el usuario.");
13
18
  const password = await askHidden("Contraseña: ");
14
19
  const credentials = { username, password };
15
- await new LocalSession().login(credentials);
20
+ let accessToken = null;
21
+ if (http)
22
+ accessToken = await loginToStore(credentials);
23
+ else
24
+ await new LocalSession().login(credentials);
16
25
  // Se guardan recién después de comprobar que el SSO las aceptó.
17
26
  if (await saveCredentials(credentials)) {
18
27
  logger.info("Credenciales guardadas en el keychain del sistema.");
@@ -20,5 +29,22 @@ export async function runLogin() {
20
29
  else {
21
30
  logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER y UTN_PASS.");
22
31
  }
23
- logger.info("Listo: ya podés usar el servidor MCP.");
32
+ if (accessToken) {
33
+ logger.info("Listo. Configurá tu cliente MCP con este header; no se vuelve a mostrar:\n\n" +
34
+ ` Authorization: Bearer ${accessToken}\n`);
35
+ }
36
+ else {
37
+ logger.info("Listo: ya podés usar el servidor MCP.");
38
+ }
39
+ }
40
+ /** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
41
+ async function loginToStore(credentials) {
42
+ const jar = new CookieJar();
43
+ const token = await fetchToken(jar, credentials);
44
+ const store = await SqliteStore.open();
45
+ const userId = await store.findOrCreateUser(credentials.username);
46
+ // En minúscula, igual que en `users`: es con lo que se compara al renovar.
47
+ const data = { username: credentials.username.toLowerCase(), token, cookies: await jar.serialize() };
48
+ await store.saveSession(userId, AULA_PLATFORM, JSON.stringify(data));
49
+ return store.issueToken(userId, { kind: "access", clientId: "local" });
24
50
  }
@@ -4,6 +4,7 @@ import { addCalendarEvent, answerChoice, deleteForumPost, editForumPost, postToF
4
4
  import { ROOT } from "./format/address.js";
5
5
  import { readAulaFile } from "./format/files.js";
6
6
  import { open } from "./format/navigate.js";
7
+ import { createUploadLink, submitAssignment } from "./format/submission.js";
7
8
  // JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
8
9
  const json = (data) => ({
9
10
  content: [{ type: "text", text: JSON.stringify(data) }],
@@ -14,7 +15,7 @@ const failure = (e) => ({
14
15
  });
15
16
  /** Para que el cliente distinga las tools que leen de las que cambian algo en el aula. */
16
17
  const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
17
- export function registerAulaVirtualTools(server, session) {
18
+ export function registerAulaVirtualTools(server, session, files) {
18
19
  server.registerTool("aula_get_site_info", {
19
20
  description: "Datos del usuario logueado en el aula virtual de la UTN FRBA y del sitio Moodle.",
20
21
  annotations: READ_ONLY,
@@ -253,6 +254,105 @@ export function registerAulaVirtualTools(server, session) {
253
254
  return failure(e);
254
255
  }
255
256
  });
257
+ // En STDIO el servidor lee el archivo del disco del usuario; en HTTP no puede, porque puede estar en
258
+ // otra máquina: los archivos llegan por un link de subida, que usa el agente o el usuario.
259
+ const newFiles = files.local
260
+ ? "Los archivos nuevos se pasan en `archivos` con su ruta absoluta en la compu del usuario."
261
+ : "Los archivos nuevos no van en la llamada: primero pedí un link con aula_assign_upload_link, " +
262
+ "subilos ahí y después pasá su id en `subida`.";
263
+ if (!files.local) {
264
+ const { inbox, owner } = files;
265
+ server.registerTool("aula_assign_upload_link", {
266
+ description: "Genera un link de un solo uso para subir los archivos de una entrega, que después se " +
267
+ "entregan con aula_assign_submit pasando el `subida` que devuelve. Vence en 30 minutos.\n\n" +
268
+ "Cómo subirlos:\n" +
269
+ "- Si podés ejecutar comandos y tenés el archivo (por ejemplo, uno que el usuario adjuntó o " +
270
+ "que está en su compu), subilo vos con el comando de `conTerminal`, cambiando la ruta. Va un " +
271
+ '`-F "archivos=@..."` por archivo, todos en el mismo comando. Responde JSON con lo recibido.\n' +
272
+ "- Si no, pasale el `url` al usuario: se abre en el navegador, también en el celu, y elige " +
273
+ "los archivos ahí. Esperá a que te diga que los subió.\n\n" +
274
+ "Subir al link no entrega nada; una subida nueva al mismo link reemplaza la anterior. " +
275
+ "`acepta` trae los límites de la tarea.",
276
+ inputSchema: z.object({
277
+ direccion: z
278
+ .string()
279
+ .describe('Dirección de la tarea, de aula_open. Ej: "/curso/29487/mod/1729138".'),
280
+ }),
281
+ annotations: { readOnlyHint: true, openWorldHint: false },
282
+ }, async ({ direccion }) => {
283
+ try {
284
+ return json(await createUploadLink(session, direccion, inbox, owner));
285
+ }
286
+ catch (e) {
287
+ return failure(e);
288
+ }
289
+ });
290
+ }
291
+ const submitSchema = z.object({
292
+ direccion: z.string().describe('Dirección de la tarea, de aula_open. Ej: "/curso/29487/mod/1729138".'),
293
+ archivos: z
294
+ .array(z.string())
295
+ .optional()
296
+ .describe(files.local
297
+ ? "Lista final de archivos. Cada uno es la ruta absoluta de un archivo en la compu del " +
298
+ "usuario o la `url` de uno que ya está en la entrega. Lista vacía: quita todos. " +
299
+ 'Ej: ["C:\\Users\\ana\\Documents\\TP3.pdf"].'
300
+ : "Lista final de los archivos que ya están en la entrega, por su `url` de aula_open; los " +
301
+ "de `subida` se suman. Lista vacía: quita los que estaban. " +
302
+ 'Ej: ["https://aulasvirtuales.frba.utn.edu.ar/pluginfile.php/123/assignsubmission_file/submission_files/456/TP3.pdf"].'),
303
+ texto: z
304
+ .string()
305
+ .optional()
306
+ .describe("Texto en línea completo, en texto plano; reemplaza al anterior. Una línea en blanco " +
307
+ 'separa párrafos. Ej: "Link al repositorio: https://github.com/ana/tp3"'),
308
+ enviar: z
309
+ .boolean()
310
+ .optional()
311
+ .describe("Solo en tareas con borradores: confirmar el envío para que cuente como entregada. Por " +
312
+ "defecto, true. Ej: false para dejarla como borrador."),
313
+ aceptoDeclaracion: z
314
+ .boolean()
315
+ .optional()
316
+ .describe("true si el usuario aceptó la `declaracion` de la tarea. Solo hace falta si la tarea la pide. Ej: true."),
317
+ });
318
+ const httpSubmitSchema = submitSchema.extend({
319
+ subida: z
320
+ .string()
321
+ .optional()
322
+ .describe("`subida` de aula_assign_upload_link, ya usado: sus archivos se suman a la entrega. " +
323
+ 'Ej: "q3Vx9kP2mW7tR1yZ8nB4cD6fG0hJ5lA".'),
324
+ });
325
+ server.registerTool("aula_assign_submit", {
326
+ description: "Entrega una tarea (assign) del aula virtual: sube archivos, escribe el texto en línea o las " +
327
+ "dos cosas, según lo que acepte la tarea. Antes, abrila con aula_open: `entrega` dice qué " +
328
+ "acepta (`acepta`, con tipos, cantidad y tamaño), si `puedeEditar` y lo que ya está subido.\n\n" +
329
+ newFiles +
330
+ " `archivos` es la lista final y reemplaza lo anterior: para sumar uno sin perder los otros, " +
331
+ "incluí la `url` de cada archivo que ya está en la entrega. Si no pasás `archivos`, quedan " +
332
+ "los que estaban; lo mismo con `texto`.\n\n" +
333
+ "Si la tarea no usa borradores, guardar ya es entregar. Si los usa (`requiereConfirmarEnvio`), " +
334
+ "la tool además confirma el envío, salvo con `enviar: false`; una vez enviada, en general no se " +
335
+ "puede cambiar. Con `declaracion`, el usuario tiene que aceptarla (`aceptoDeclaracion`). " +
336
+ "Devuelve cómo quedó la entrega." +
337
+ CONFIRM_FIRST +
338
+ " Incluí la tarea, los nombres de los archivos y si se confirma el envío.",
339
+ // `subida` existe solo en HTTP; el cast es para que el handler la vea en los dos modos.
340
+ inputSchema: (files.local ? submitSchema : httpSubmitSchema),
341
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
342
+ }, async ({ direccion, archivos, subida, texto, enviar, aceptoDeclaracion }) => {
343
+ try {
344
+ return json(await submitAssignment(session, direccion, {
345
+ files: archivos,
346
+ upload: subida,
347
+ text: texto,
348
+ send: enviar ?? true,
349
+ acceptStatement: aceptoDeclaracion ?? false,
350
+ }, files));
351
+ }
352
+ catch (e) {
353
+ return failure(e);
354
+ }
355
+ });
256
356
  server.registerTool("aula_calendar_add_event", {
257
357
  description: "Agrega un evento personal al calendario del aula virtual, que solo ve el usuario: por ejemplo " +
258
358
  "un recordatorio de un parcial o una entrega. El aula avisa según la configuración de " +
package/build/http.js CHANGED
@@ -1,19 +1,137 @@
1
1
  import express from "express";
2
- import { createMcpHandler } from "@modelcontextprotocol/server";
2
+ import { Readable } from "node:stream";
3
+ import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, OAuthErrorCode, verifyBearerToken, } from "@modelcontextprotocol/server";
3
4
  import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
4
5
  import { logger } from "./shared/logger.js";
6
+ import { MAX_UPLOAD_BYTES, formatSize } from "./shared/uploads.js";
5
7
  const asMiddleware = (guard) => (req, res, next) => {
6
8
  if (guard(req, res))
7
9
  next();
8
10
  };
9
- export function startHttpServer(factory, port) {
11
+ /**
12
+ * El SDK rechaza los tokens sin vencimiento; al token fijo del modo local le ponemos uno lejano.
13
+ * Segundos desde epoch: 1/1/3000.
14
+ */
15
+ const NEVER = 32_503_680_000;
16
+ /** El usuario del store dueño del token, en `extra.userId`. */
17
+ export function userIdOf(authInfo) {
18
+ const userId = authInfo?.extra?.userId;
19
+ if (typeof userId !== "number")
20
+ throw new Error("Request sin usuario autenticado.");
21
+ return userId;
22
+ }
23
+ /** Busca el token en el store: el MCP solo acepta tokens de acceso vigentes. */
24
+ function storeVerifier(store) {
25
+ return {
26
+ async verifyAccessToken(token) {
27
+ const found = await store.findToken(token, "access");
28
+ if (!found)
29
+ throw new OAuthError(OAuthErrorCode.InvalidToken, "Token desconocido, vencido o revocado.");
30
+ return {
31
+ token,
32
+ clientId: found.clientId ?? "desconocido",
33
+ scopes: [],
34
+ expiresAt: found.expiresAt ?? NEVER,
35
+ extra: { userId: found.userId },
36
+ };
37
+ },
38
+ };
39
+ }
40
+ /** El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión. */
41
+ export function startHttpServer(factory, port, store, inbox) {
10
42
  const handler = createMcpHandler(factory, { onerror: (e) => logger.error(e.message) });
43
+ const verifier = storeVerifier(store);
44
+ const requireToken = async (req, res, next) => {
45
+ try {
46
+ // toNodeHandler le pasa `req.auth` al handler como `authInfo`.
47
+ req.auth = await verifyBearerToken(req.headers.authorization, { verifier });
48
+ next();
49
+ }
50
+ catch (e) {
51
+ const challenge = bearerAuthChallengeResponse(e);
52
+ res.status(challenge.status);
53
+ challenge.headers.forEach((value, name) => res.setHeader(name, value));
54
+ res.send(await challenge.text());
55
+ }
56
+ };
11
57
  const app = express();
12
58
  app.use(asMiddleware(localhostHostValidation()));
13
59
  app.use(asMiddleware(localhostOriginValidation()));
14
60
  // Sin express.json(): el handler lee el body por su cuenta.
15
- app.all("/mcp", toNodeHandler(handler));
61
+ app.all("/mcp", requireToken, toNodeHandler(handler));
62
+ // Sin bearer: el id del link es el secreto, y lo abre un navegador o un `curl` que no lo tienen.
63
+ app.get("/subir/:id", (req, res) => {
64
+ const upload = inbox.get(req.params.id);
65
+ if (!upload)
66
+ return void res.status(404).type("html").send(page("Link vencido", EXPIRED));
67
+ res.type("html").send(page(upload.title, uploadForm(upload.limits)));
68
+ });
69
+ app.post("/subir/:id", (req, res) => receiveUpload(inbox, req, res));
16
70
  app.listen(port, "127.0.0.1", () => {
17
71
  logger.info(`Servidor MCP escuchando en http://127.0.0.1:${port}/mcp`);
18
72
  });
19
73
  }
74
+ /** Recibe el multipart del formulario o de `curl -F archivos=@...`. Al navegador le contesta HTML; al resto, JSON. */
75
+ async function receiveUpload(inbox, req, res) {
76
+ const browser = req.accepts(["json", "html"]) === "html";
77
+ const fail = (status, message) => browser
78
+ ? res.status(status).type("html").send(page("No se pudo subir", `<p>${escapeHtml(message)}</p>`))
79
+ : res.status(status).json({ error: message });
80
+ const id = String(req.params.id);
81
+ if (!inbox.get(id))
82
+ return void fail(404, "El link de subida venció o no existe.");
83
+ if (Number(req.headers["content-length"]) > MAX_UPLOAD_BYTES) {
84
+ return void fail(413, `La subida pasa el máximo de ${formatSize(MAX_UPLOAD_BYTES)}.`);
85
+ }
86
+ try {
87
+ // Request de la API web: parsea multipart sin sumar dependencias.
88
+ const request = new Request(`http://localhost${req.url}`, {
89
+ method: "POST",
90
+ headers: req.headers,
91
+ body: Readable.toWeb(req),
92
+ duplex: "half",
93
+ });
94
+ const form = await request.formData();
95
+ const files = await Promise.all(form
96
+ .getAll("archivos")
97
+ .filter((v) => typeof v !== "string" && v.size > 0)
98
+ .map(async (f) => ({ name: f.name, bytes: new Uint8Array(await f.arrayBuffer()) })));
99
+ inbox.receive(id, files);
100
+ const received = files.map((f) => ({ nombre: f.name, tamanio: f.bytes.length }));
101
+ if (browser) {
102
+ const list = received.map((f) => `<li>${escapeHtml(f.nombre)} (${formatSize(f.tamanio)})</li>`).join("");
103
+ res.type("html").send(page("Listo", `<ul>${list}</ul><p>Volvé al chat y avisá que ya los subiste.</p>`));
104
+ }
105
+ else {
106
+ res.json({ recibidos: received });
107
+ }
108
+ }
109
+ catch (e) {
110
+ fail(400, e.message);
111
+ }
112
+ }
113
+ const EXPIRED = "<p>Este link de subida venció o ya no existe. Pedile al agente uno nuevo.</p>";
114
+ function uploadForm(limits) {
115
+ const rules = [
116
+ limits.maxFiles ? `hasta ${limits.maxFiles} archivo(s)` : "",
117
+ `hasta ${formatSize(limits.maxBytes)} cada uno`,
118
+ limits.extensions?.length ? `tipos: ${limits.extensions.join(", ")}` : "",
119
+ ].filter(Boolean);
120
+ const accept = limits.extensions?.length ? ` accept="${escapeHtml(limits.extensions.join(","))}"` : "";
121
+ return (`<p>Elegí los archivos para la entrega (${escapeHtml(rules.join(", "))}).</p>` +
122
+ `<form method="post" enctype="multipart/form-data">` +
123
+ `<input type="file" name="archivos" multiple required${accept}>` +
124
+ `<button type="submit">Subir</button></form>` +
125
+ "<p class=nota>Subirlos no entrega la tarea: después el agente te confirma y la entrega.</p>");
126
+ }
127
+ /** Página mínima: se abre desde el celu, así que nada más que lo necesario. */
128
+ function page(title, body) {
129
+ return (`<!doctype html><html lang="es"><head><meta charset="utf-8">` +
130
+ `<meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>` +
131
+ "<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:2rem auto;padding:0 1rem;" +
132
+ "line-height:1.5;color:#1a1a1a;background:#fff}@media(prefers-color-scheme:dark){body{color:#eee;" +
133
+ "background:#151515}}form{display:flex;flex-direction:column;gap:1rem;margin:1.5rem 0}" +
134
+ "button{padding:.7rem;font-size:1rem}.nota{opacity:.7;font-size:.9rem}</style></head>" +
135
+ `<body><h1>${escapeHtml(title)}</h1>${body}</body></html>`);
136
+ }
137
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
package/build/index.js CHANGED
@@ -2,14 +2,18 @@
2
2
  import { parseArgs } from "node:util";
3
3
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
4
  import { LocalSession } from "./aula-virtual/client/local-session.js";
5
+ import { StoredAulaSession } from "./aula-virtual/client/stored-session.js";
5
6
  import { runLogin } from "./aula-virtual/login.js";
6
- import { startHttpServer } from "./http.js";
7
+ import { startHttpServer, userIdOf } from "./http.js";
7
8
  import { createServer } from "./server.js";
8
9
  import { logger } from "./shared/logger.js";
9
- const USAGE = `Uso:
10
- utn-mcp servidor MCP por STDIO (lo lanza el cliente)
11
- utn-mcp --http [--port N] servidor MCP por HTTP en 127.0.0.1 (por defecto 3000)
12
- utn-mcp login guardar credenciales de la UTN y obtener el primer token`;
10
+ import { SqliteStore } from "./shared/store/sqlite-store.js";
11
+ import { UploadInbox } from "./shared/uploads.js";
12
+ const USAGE = `Uso:
13
+ utn-mcp servidor MCP por STDIO (lo lanza el cliente)
14
+ utn-mcp --http [--port N] servidor MCP por HTTP en 127.0.0.1 (por defecto 3000)
15
+ utn-mcp login guardar credenciales de la UTN y obtener el primer token
16
+ utn-mcp login --http lo mismo, para el modo HTTP: guarda la sesión y emite el token del cliente`;
13
17
  async function main() {
14
18
  const { values, positionals } = parseArgs({
15
19
  allowPositionals: true,
@@ -24,17 +28,25 @@ async function main() {
24
28
  return;
25
29
  }
26
30
  if (positionals[0] === "login") {
27
- await runLogin();
31
+ await runLogin(values.http);
28
32
  return;
29
33
  }
30
- // Modo local: un solo usuario, la misma sesión para todas las conexiones.
31
- const aula = new LocalSession();
32
- const factory = () => createServer(aula);
33
34
  if (values.http) {
34
- startHttpServer(factory, Number(values.port));
35
+ // Cada request trae el token de un usuario del store y se arma con su sesión. Los archivos
36
+ // llegan por links de subida: el servidor puede no estar en la compu de quien entrega.
37
+ const port = Number(values.port);
38
+ const store = await SqliteStore.open();
39
+ const inbox = new UploadInbox(process.env.UTN_MCP_PUBLIC_URL ?? `http://127.0.0.1:${port}`);
40
+ const factory = (ctx) => {
41
+ const userId = userIdOf(ctx.authInfo);
42
+ return createServer(new StoredAulaSession(store, userId), { local: false, inbox, owner: String(userId) });
43
+ };
44
+ startHttpServer(factory, port, store, inbox);
35
45
  }
36
46
  else {
37
- serveStdio(factory, { onerror: (e) => logger.error(e.message) });
47
+ // STDIO: un solo usuario, el de la compu, con la misma sesión para todas las conexiones.
48
+ const aula = new LocalSession();
49
+ serveStdio(() => createServer(aula, { local: true }), { onerror: (e) => logger.error(e.message) });
38
50
  logger.info("Servidor MCP iniciado en STDIO");
39
51
  }
40
52
  }
package/build/server.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
3
- /** Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request. */
4
- export function createServer(aula) {
3
+ /**
4
+ * Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
5
+ * `files`: de dónde salen los archivos que el usuario sube a una plataforma.
6
+ */
7
+ export function createServer(aula, files) {
5
8
  const server = new McpServer({
6
9
  name: "UTN-MCP",
7
- version: "0.6.7",
10
+ version: "0.6.9",
8
11
  });
9
- registerAulaVirtualTools(server, aula);
12
+ registerAulaVirtualTools(server, aula, files);
10
13
  return server;
11
14
  }
@@ -11,3 +11,7 @@ export function configDir() {
11
11
  }
12
12
  /** Token de Moodle + cookies del SSO del modo local. */
13
13
  export const sessionFile = () => join(configDir(), "session.json");
14
+ /** Base de usuarios, tokens y sesiones del modo HTTP: UTN_MCP_DB o un SQLite en la carpeta de configuración. */
15
+ export const storeFile = () => process.env.UTN_MCP_DB ?? join(configDir(), "utn-mcp.db");
16
+ /** Clave con la que se cifran las sesiones en la base, si no viene en UTN_MCP_KEY. */
17
+ export const storeKeyFile = () => join(configDir(), "store.key");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,145 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { storeFile, storeKeyFile } from "../paths.js";
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS users (
7
+ id INTEGER PRIMARY KEY,
8
+ utn_username TEXT NOT NULL UNIQUE,
9
+ created_at INTEGER NOT NULL
10
+ );
11
+ CREATE TABLE IF NOT EXISTS tokens (
12
+ hash TEXT PRIMARY KEY,
13
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
14
+ kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')),
15
+ client_id TEXT,
16
+ expires_at INTEGER,
17
+ created_at INTEGER NOT NULL
18
+ );
19
+ CREATE INDEX IF NOT EXISTS tokens_user ON tokens(user_id);
20
+ CREATE TABLE IF NOT EXISTS platform_sessions (
21
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
22
+ platform TEXT NOT NULL,
23
+ data BLOB NOT NULL,
24
+ updated_at INTEGER NOT NULL,
25
+ PRIMARY KEY (user_id, platform)
26
+ );
27
+ `;
28
+ /** Bytes de la clave AES-256. */
29
+ const KEY_BYTES = 32;
30
+ /** Bytes del IV de AES-GCM. */
31
+ const IV_BYTES = 12;
32
+ /** Bytes del tag de autenticación de AES-GCM. */
33
+ const TAG_BYTES = 16;
34
+ const now = () => Math.floor(Date.now() / 1000);
35
+ const hashToken = (token) => createHash("sha256").update(token).digest("hex");
36
+ /**
37
+ * El SessionStore sobre SQLite. Las sesiones van cifradas con AES-256-GCM: tienen el token de Moodle
38
+ * y las cookies del SSO, que alcanzan para entrar al aula como el usuario. Los tokens se guardan
39
+ * hasheados, así que una copia de la base no sirve para usar el servidor.
40
+ */
41
+ export class SqliteStore {
42
+ db;
43
+ key;
44
+ constructor(db, key) {
45
+ this.db = db;
46
+ this.key = key;
47
+ }
48
+ /** `node:sqlite` llega con Node 22.13; se importa acá para que el modo STDIO siga andando en Node 20. */
49
+ static async open(path = storeFile()) {
50
+ let sqlite;
51
+ try {
52
+ sqlite = await import("node:sqlite");
53
+ }
54
+ catch {
55
+ throw new Error(`El modo HTTP necesita Node 22.13 o más nuevo; tenés ${process.version}.`);
56
+ }
57
+ await mkdir(dirname(path), { recursive: true });
58
+ const db = new sqlite.DatabaseSync(path);
59
+ // WAL: el login escribe mientras el servidor lee, desde otro proceso.
60
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
61
+ db.exec(SCHEMA);
62
+ return new SqliteStore(db, await loadKey());
63
+ }
64
+ async findOrCreateUser(utnUsername) {
65
+ const username = utnUsername.trim().toLowerCase();
66
+ this.db
67
+ .prepare("INSERT INTO users (utn_username, created_at) VALUES (?, ?) ON CONFLICT DO NOTHING")
68
+ .run(username, now());
69
+ const row = this.db.prepare("SELECT id FROM users WHERE utn_username = ?").get(username);
70
+ return row.id;
71
+ }
72
+ async issueToken(userId, options) {
73
+ const token = randomBytes(32).toString("base64url");
74
+ const createdAt = now();
75
+ this.db
76
+ .prepare("INSERT INTO tokens (hash, user_id, kind, client_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)")
77
+ .run(hashToken(token), userId, options.kind, options.clientId ?? null, options.ttlSeconds ? createdAt + options.ttlSeconds : null, createdAt);
78
+ return token;
79
+ }
80
+ async findToken(token, kind) {
81
+ const row = this.db
82
+ .prepare("SELECT user_id, client_id, expires_at FROM tokens " +
83
+ "WHERE hash = ? AND kind = ? AND (expires_at IS NULL OR expires_at > ?)")
84
+ .get(hashToken(token), kind, now());
85
+ return row ? { userId: row.user_id, clientId: row.client_id, expiresAt: row.expires_at } : null;
86
+ }
87
+ async revokeToken(token) {
88
+ this.db.prepare("DELETE FROM tokens WHERE hash = ?").run(hashToken(token));
89
+ }
90
+ async loadSession(userId, platform) {
91
+ const row = this.db
92
+ .prepare("SELECT data FROM platform_sessions WHERE user_id = ? AND platform = ?")
93
+ .get(userId, platform);
94
+ return row ? this.decrypt(row.data) : null;
95
+ }
96
+ async saveSession(userId, platform, data) {
97
+ this.db
98
+ .prepare("INSERT INTO platform_sessions (user_id, platform, data, updated_at) VALUES (?, ?, ?, ?) " +
99
+ "ON CONFLICT (user_id, platform) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at")
100
+ .run(userId, platform, this.encrypt(data), now());
101
+ }
102
+ /** iv + tag + texto cifrado, en un solo blob. */
103
+ encrypt(plain) {
104
+ const iv = randomBytes(IV_BYTES);
105
+ const cipher = createCipheriv("aes-256-gcm", this.key, iv);
106
+ const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
107
+ return Buffer.concat([iv, cipher.getAuthTag(), body]);
108
+ }
109
+ decrypt(blob) {
110
+ const buf = Buffer.from(blob);
111
+ const decipher = createDecipheriv("aes-256-gcm", this.key, buf.subarray(0, IV_BYTES));
112
+ decipher.setAuthTag(buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
113
+ try {
114
+ return Buffer.concat([decipher.update(buf.subarray(IV_BYTES + TAG_BYTES)), decipher.final()]).toString("utf8");
115
+ }
116
+ catch {
117
+ throw new Error("No se pudo descifrar la sesión: la clave (UTN_MCP_KEY o store.key) no es la que la cifró.");
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * UTN_MCP_KEY (32 bytes en base64) en el servidor hosteado, donde la clave no tiene que estar en el
123
+ * mismo disco que la base. En local, si no está, se genera una en la carpeta de configuración.
124
+ */
125
+ async function loadKey() {
126
+ const fromEnv = process.env.UTN_MCP_KEY;
127
+ if (fromEnv) {
128
+ const key = Buffer.from(fromEnv, "base64");
129
+ if (key.length !== KEY_BYTES)
130
+ throw new Error(`UTN_MCP_KEY tiene que ser de ${KEY_BYTES} bytes en base64.`);
131
+ return key;
132
+ }
133
+ const file = storeKeyFile();
134
+ try {
135
+ return Buffer.from((await readFile(file, "utf8")).trim(), "base64");
136
+ }
137
+ catch {
138
+ const key = randomBytes(KEY_BYTES);
139
+ await mkdir(dirname(file), { recursive: true });
140
+ // `wx`: si otro proceso la creó recién, no la pisamos.
141
+ await writeFile(file, key.toString("base64"), { mode: 0o600, flag: "wx" }).catch(() => { });
142
+ await chmod(file, 0o600).catch(() => { }); // no-op en Windows
143
+ return Buffer.from((await readFile(file, "utf8")).trim(), "base64");
144
+ }
145
+ }
@@ -0,0 +1,80 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { extname } from "node:path";
3
+ /** Cuánto dura un link de subida, en milisegundos. */
4
+ const UPLOAD_TTL_MS = 30 * 60 * 1000;
5
+ /** Tope de una subida entera, en bytes: los archivos quedan en memoria hasta que se entregan. */
6
+ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
7
+ /**
8
+ * Links de un solo uso para recibir archivos por HTTP: los sube el agente con `curl` si tiene
9
+ * terminal, o el usuario desde el navegador. Los archivos quedan en memoria hasta que una tool los
10
+ * toma o el link vence; si el servidor se reinicia, se pierden y hay que pedir otro link.
11
+ */
12
+ export class UploadInbox {
13
+ baseUrl;
14
+ uploads = new Map();
15
+ constructor(baseUrl) {
16
+ this.baseUrl = baseUrl;
17
+ }
18
+ open(owner, title, limits) {
19
+ this.sweep();
20
+ const id = randomBytes(24).toString("base64url");
21
+ const expiresAt = Date.now() + UPLOAD_TTL_MS;
22
+ this.uploads.set(id, { owner, title, limits, files: null, expiresAt });
23
+ return { id, url: `${this.baseUrl}/subir/${id}`, expiresAt: new Date(expiresAt).toISOString() };
24
+ }
25
+ /** La subida para mostrar su formulario, o undefined si no existe o venció. */
26
+ get(id) {
27
+ this.sweep();
28
+ return this.uploads.get(id);
29
+ }
30
+ /** Guarda los archivos de una subida; una subida nueva al mismo link reemplaza la anterior. */
31
+ receive(id, files) {
32
+ const upload = this.uploads.get(id);
33
+ if (!upload || upload.expiresAt < Date.now())
34
+ throw new Error("El link de subida venció o no existe.");
35
+ if (!files.length)
36
+ throw new Error('No llegó ningún archivo: van en el campo "archivos".');
37
+ checkFiles(files, upload.limits);
38
+ upload.files = files;
39
+ }
40
+ /** Los archivos que se subieron, para entregarlos. No los borra: si la entrega falla, se reintenta. */
41
+ take(id, owner) {
42
+ const upload = this.uploads.get(id);
43
+ if (!upload || upload.owner !== owner || upload.expiresAt < Date.now()) {
44
+ throw new Error("La subida venció o no existe: pedí otro link.");
45
+ }
46
+ if (!upload.files) {
47
+ throw new Error("Todavía no se subió nada a ese link: esperá a que se use y volvé a intentar.");
48
+ }
49
+ return upload.files;
50
+ }
51
+ close(id) {
52
+ this.uploads.delete(id);
53
+ }
54
+ sweep() {
55
+ const now = Date.now();
56
+ for (const [id, upload] of this.uploads)
57
+ if (upload.expiresAt < now)
58
+ this.uploads.delete(id);
59
+ }
60
+ }
61
+ /** Cantidad, tamaño, tipo y nombres repetidos, que Moodle no acepta. */
62
+ export function checkFiles(files, limits) {
63
+ if (limits.maxFiles && files.length > limits.maxFiles) {
64
+ throw new Error(`Se aceptan hasta ${limits.maxFiles} archivo(s) y llegaron ${files.length}.`);
65
+ }
66
+ const seen = new Set();
67
+ for (const f of files) {
68
+ if (f.bytes.length > limits.maxBytes) {
69
+ throw new Error(`"${f.name}" pesa ${formatSize(f.bytes.length)}; el máximo es ${formatSize(limits.maxBytes)} por archivo.`);
70
+ }
71
+ if (limits.extensions?.length && !limits.extensions.includes(extname(f.name).toLowerCase())) {
72
+ throw new Error(`"${f.name}" no es de un tipo aceptado (${limits.extensions.join(", ")}).`);
73
+ }
74
+ if (seen.has(f.name))
75
+ throw new Error(`Hay dos archivos que se llaman "${f.name}"; renombrá uno.`);
76
+ seen.add(f.name);
77
+ }
78
+ }
79
+ /** Tamaño para personas: en KB por debajo del mega, para que un archivo chico no diga "0.0 MB". */
80
+ export const formatSize = (bytes) => bytes < 1024 * 1024 ? `${Math.ceil(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utn-mcp",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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": {