utn-mcp 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,25 @@
1
1
  import { ask, askHidden } from "../shared/input.js";
2
2
  import { logger } from "../shared/logger.js";
3
+ import { SqliteStore } from "../shared/store/sqlite-store.js";
3
4
  import { saveCredentials } from "./client/credentials.js";
5
+ import { linkAulaAccount } from "./client/link.js";
4
6
  import { LocalSession } from "./client/local-session.js";
5
7
  /**
6
8
  * `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
7
9
  * y obtiene el primer token. Después el servidor renueva solo.
10
+ * Con `--http` guarda la sesión en el store y emite el token para el cliente MCP.
8
11
  */
9
- export async function runLogin() {
10
- const username = await ask("Usuario institucional de la UTN: ");
12
+ export async function runLogin(http) {
13
+ const username = (await ask("Usuario institucional de la UTN: ")).trim();
11
14
  if (!username)
12
15
  throw new Error("Falta el usuario.");
13
16
  const password = await askHidden("Contraseña: ");
14
17
  const credentials = { username, password };
15
- await new LocalSession().login(credentials);
18
+ let accessToken = null;
19
+ if (http)
20
+ accessToken = await loginToStore(credentials);
21
+ else
22
+ await new LocalSession().login(credentials);
16
23
  // Se guardan recién después de comprobar que el SSO las aceptó.
17
24
  if (await saveCredentials(credentials)) {
18
25
  logger.info("Credenciales guardadas en el keychain del sistema.");
@@ -20,5 +27,17 @@ export async function runLogin() {
20
27
  else {
21
28
  logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER y UTN_PASS.");
22
29
  }
23
- logger.info("Listo: ya podés usar el servidor MCP.");
30
+ if (accessToken) {
31
+ logger.info("Listo. Configurá tu cliente MCP con este header; no se vuelve a mostrar:\n\n" +
32
+ ` Authorization: Bearer ${accessToken}\n`);
33
+ }
34
+ else {
35
+ logger.info("Listo: ya podés usar el servidor MCP.");
36
+ }
37
+ }
38
+ /** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
39
+ async function loginToStore(credentials) {
40
+ const store = await SqliteStore.open();
41
+ const { userId } = await linkAulaAccount(store, credentials);
42
+ return store.issueToken(userId, { kind: "access", clientId: "local" });
24
43
  }
@@ -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,150 @@
1
1
  import express from "express";
2
- import { createMcpHandler } from "@modelcontextprotocol/server";
3
- import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
2
+ import { Readable } from "node:stream";
3
+ import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, OAuthErrorCode, verifyBearerToken, } from "@modelcontextprotocol/server";
4
+ import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node";
5
+ import { mountAdmin } from "./oauth/admin.js";
6
+ import { mountOAuth } from "./oauth/server.js";
4
7
  import { logger } from "./shared/logger.js";
8
+ import { escapeHtml, page } from "./shared/pages.js";
9
+ import { MAX_UPLOAD_BYTES, formatSize } from "./shared/uploads.js";
5
10
  const asMiddleware = (guard) => (req, res, next) => {
6
11
  if (guard(req, res))
7
12
  next();
8
13
  };
9
- export function startHttpServer(factory, port) {
14
+ /**
15
+ * El SDK rechaza los tokens sin vencimiento; al token fijo del modo local le ponemos uno lejano.
16
+ * Segundos desde epoch: 1/1/3000.
17
+ */
18
+ const NEVER = 32_503_680_000;
19
+ /** El usuario del store dueño del token, en `extra.userId`. */
20
+ export function userIdOf(authInfo) {
21
+ const userId = authInfo?.extra?.userId;
22
+ if (typeof userId !== "number")
23
+ throw new Error("Request sin usuario autenticado.");
24
+ return userId;
25
+ }
26
+ /** Busca el token en el store: el MCP solo acepta tokens de acceso vigentes. */
27
+ function storeVerifier(store) {
28
+ return {
29
+ async verifyAccessToken(token) {
30
+ const found = await store.findToken(token, "access");
31
+ if (!found)
32
+ throw new OAuthError(OAuthErrorCode.InvalidToken, "Token desconocido, vencido o revocado.");
33
+ return {
34
+ token,
35
+ clientId: found.clientId ?? "desconocido",
36
+ scopes: [],
37
+ expiresAt: found.expiresAt ?? NEVER,
38
+ extra: { userId: found.userId },
39
+ };
40
+ },
41
+ };
42
+ }
43
+ const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
44
+ /** El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión. */
45
+ export function startHttpServer(factory, options, store, inbox) {
46
+ const { host, port, publicUrl, access, adminSecret } = options;
10
47
  const handler = createMcpHandler(factory, { onerror: (e) => logger.error(e.message) });
48
+ const verifier = storeVerifier(store);
11
49
  const app = express();
12
- app.use(asMiddleware(localhostHostValidation()));
13
- app.use(asMiddleware(localhostOriginValidation()));
50
+ // Detrás de un reverse proxy en la misma máquina: la IP del cliente (para el rate limit) viene en X-Forwarded-For.
51
+ app.set("trust proxy", "loopback");
52
+ // Contra DNS rebinding: solo se aceptan los hosts de esta máquina y el de la URL pública.
53
+ const allowed = publicUrl ? [...LOOPBACK, new URL(publicUrl).hostname] : LOOPBACK;
54
+ if (!publicUrl && !LOOPBACK.includes(host)) {
55
+ logger.warn(`Escuchando en ${host} sin UTN_MCP_PUBLIC_URL: se rechazan los requests que no lleguen como localhost.`);
56
+ }
57
+ app.use(asMiddleware(hostHeaderValidation(allowed)));
58
+ // Origin solo donde un navegador ajeno no tiene nada que hacer; los endpoints de OAuth tienen CORS abierto.
59
+ const sameOrigin = asMiddleware(originValidation(allowed));
60
+ const resourceMetadataUrl = mountOAuth(app, {
61
+ issuer: publicUrl ?? `http://127.0.0.1:${port}`,
62
+ store,
63
+ access,
64
+ sameOrigin,
65
+ });
66
+ if (adminSecret)
67
+ mountAdmin(app, store, adminSecret);
68
+ const requireToken = async (req, res, next) => {
69
+ try {
70
+ // toNodeHandler le pasa `req.auth` al handler como `authInfo`.
71
+ req.auth = await verifyBearerToken(req.headers.authorization, { verifier });
72
+ next();
73
+ }
74
+ catch (e) {
75
+ // El 401 dice dónde está la metadata: de ahí el cliente arranca el OAuth.
76
+ const challenge = bearerAuthChallengeResponse(e, { resourceMetadataUrl });
77
+ res.status(challenge.status);
78
+ challenge.headers.forEach((value, name) => res.setHeader(name, value));
79
+ res.send(await challenge.text());
80
+ }
81
+ };
14
82
  // Sin express.json(): el handler lee el body por su cuenta.
15
- app.all("/mcp", toNodeHandler(handler));
16
- app.listen(port, "127.0.0.1", () => {
17
- logger.info(`Servidor MCP escuchando en http://127.0.0.1:${port}/mcp`);
83
+ app.all("/mcp", sameOrigin, requireToken, toNodeHandler(handler));
84
+ // Sin bearer: el id del link es el secreto, y lo abre un navegador o un `curl` que no lo tienen.
85
+ app.get("/subir/:id", sameOrigin, (req, res) => {
86
+ const upload = inbox.get(String(req.params.id));
87
+ if (!upload)
88
+ return void res.status(404).type("html").send(page("Link vencido", EXPIRED));
89
+ res.type("html").send(page(upload.title, uploadForm(upload.limits)));
18
90
  });
91
+ app.post("/subir/:id", sameOrigin, (req, res) => receiveUpload(inbox, req, res));
92
+ app.listen(port, host, () => {
93
+ logger.info(`Servidor MCP escuchando en http://${host}:${port}/mcp`);
94
+ if (publicUrl)
95
+ logger.info(`Publicado en ${publicUrl}/mcp`);
96
+ });
97
+ }
98
+ /** Recibe el multipart del formulario o de `curl -F archivos=@...`. Al navegador le contesta HTML; al resto, JSON. */
99
+ async function receiveUpload(inbox, req, res) {
100
+ const browser = req.accepts(["json", "html"]) === "html";
101
+ const fail = (status, message) => browser
102
+ ? res.status(status).type("html").send(page("No se pudo subir", `<p>${escapeHtml(message)}</p>`))
103
+ : res.status(status).json({ error: message });
104
+ const id = String(req.params.id);
105
+ if (!inbox.get(id))
106
+ return void fail(404, "El link de subida venció o no existe.");
107
+ if (Number(req.headers["content-length"]) > MAX_UPLOAD_BYTES) {
108
+ return void fail(413, `La subida pasa el máximo de ${formatSize(MAX_UPLOAD_BYTES)}.`);
109
+ }
110
+ try {
111
+ // Request de la API web: parsea multipart sin sumar dependencias.
112
+ const request = new Request(`http://localhost${req.url}`, {
113
+ method: "POST",
114
+ headers: req.headers,
115
+ body: Readable.toWeb(req),
116
+ duplex: "half",
117
+ });
118
+ const form = await request.formData();
119
+ const files = await Promise.all(form
120
+ .getAll("archivos")
121
+ .filter((v) => typeof v !== "string" && v.size > 0)
122
+ .map(async (f) => ({ name: f.name, bytes: new Uint8Array(await f.arrayBuffer()) })));
123
+ inbox.receive(id, files);
124
+ const received = files.map((f) => ({ nombre: f.name, tamanio: f.bytes.length }));
125
+ if (browser) {
126
+ const list = received.map((f) => `<li>${escapeHtml(f.nombre)} (${formatSize(f.tamanio)})</li>`).join("");
127
+ res.type("html").send(page("Listo", `<ul>${list}</ul><p>Volvé al chat y avisá que ya los subiste.</p>`));
128
+ }
129
+ else {
130
+ res.json({ recibidos: received });
131
+ }
132
+ }
133
+ catch (e) {
134
+ fail(400, e.message);
135
+ }
136
+ }
137
+ const EXPIRED = "<p>Este link de subida venció o ya no existe. Pedile al agente uno nuevo.</p>";
138
+ function uploadForm(limits) {
139
+ const rules = [
140
+ limits.maxFiles ? `hasta ${limits.maxFiles} archivo(s)` : "",
141
+ `hasta ${formatSize(limits.maxBytes)} cada uno`,
142
+ limits.extensions?.length ? `tipos: ${limits.extensions.join(", ")}` : "",
143
+ ].filter(Boolean);
144
+ const accept = limits.extensions?.length ? ` accept="${escapeHtml(limits.extensions.join(","))}"` : "";
145
+ return (`<p>Elegí los archivos para la entrega (${escapeHtml(rules.join(", "))}).</p>` +
146
+ `<form method="post" enctype="multipart/form-data">` +
147
+ `<input type="file" name="archivos" multiple required${accept}>` +
148
+ `<button type="submit">Subir</button></form>` +
149
+ "<p class=nota>Subirlos no entrega la tarea: después el agente te confirma y la entrega.</p>");
19
150
  }
package/build/index.js CHANGED
@@ -1,20 +1,36 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
2
3
  import { parseArgs } from "node:util";
3
4
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
5
  import { LocalSession } from "./aula-virtual/client/local-session.js";
6
+ import { StoredAulaSession } from "./aula-virtual/client/stored-session.js";
5
7
  import { runLogin } from "./aula-virtual/login.js";
6
- import { startHttpServer } from "./http.js";
8
+ import { startHttpServer, userIdOf } from "./http.js";
9
+ import { accessPolicyFromEnv } from "./oauth/access.js";
7
10
  import { createServer } from "./server.js";
8
11
  import { logger } from "./shared/logger.js";
12
+ import { SqliteStore } from "./shared/store/sqlite-store.js";
13
+ import { UploadInbox } from "./shared/uploads.js";
9
14
  const USAGE = `Uso:
10
15
  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`;
16
+ utn-mcp --http [--host H] [--port N] servidor MCP por HTTP (por defecto 127.0.0.1:3000)
17
+ utn-mcp login guardar credenciales de la UTN y obtener el primer token
18
+ utn-mcp login --http lo mismo, para el modo HTTP: guarda la sesión y emite el token del cliente
19
+
20
+ Si hay un .env en la carpeta actual, se lee al arrancar (ver .env.example).`;
21
+ /** Lee el `.env` de la carpeta actual, si hay. Lo que ya está definido en el entorno no se pisa. */
22
+ function loadDotenv() {
23
+ if (existsSync(".env"))
24
+ process.loadEnvFile(".env");
25
+ }
13
26
  async function main() {
27
+ loadDotenv();
14
28
  const { values, positionals } = parseArgs({
15
29
  allowPositionals: true,
16
30
  options: {
17
31
  http: { type: "boolean", default: false },
32
+ // Por defecto solo desde esta máquina; hosteado, `0.0.0.0` detrás del reverse proxy.
33
+ host: { type: "string", default: process.env.HOST ?? "127.0.0.1" },
18
34
  port: { type: "string", default: process.env.PORT ?? "3000" },
19
35
  help: { type: "boolean", short: "h", default: false },
20
36
  },
@@ -24,17 +40,37 @@ async function main() {
24
40
  return;
25
41
  }
26
42
  if (positionals[0] === "login") {
27
- await runLogin();
43
+ await runLogin(values.http);
28
44
  return;
29
45
  }
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
46
  if (values.http) {
34
- startHttpServer(factory, Number(values.port));
47
+ // Cada request trae el token de un usuario del store y se arma con su sesión. Los archivos
48
+ // llegan por links de subida: el servidor puede no estar en la compu de quien entrega.
49
+ const port = Number(values.port);
50
+ const publicUrl = process.env.UTN_MCP_PUBLIC_URL?.replace(/\/+$/, "") || undefined;
51
+ const baseUrl = publicUrl ?? `http://127.0.0.1:${port}`;
52
+ // La página de `/login` del propio servidor, salvo que el que hostea prefiera mandar a otra.
53
+ const loginUrl = process.env.UTN_MCP_LOGIN_URL || `${baseUrl}/login`;
54
+ const store = await SqliteStore.open();
55
+ const inbox = new UploadInbox(baseUrl);
56
+ const factory = (ctx) => {
57
+ const userId = userIdOf(ctx.authInfo);
58
+ const aula = new StoredAulaSession(store, userId, loginUrl);
59
+ return createServer(aula, { local: false, inbox, owner: String(userId) });
60
+ };
61
+ const options = {
62
+ host: values.host,
63
+ port,
64
+ publicUrl,
65
+ access: accessPolicyFromEnv(),
66
+ adminSecret: process.env.UTN_MCP_ADMIN_SECRET || undefined,
67
+ };
68
+ startHttpServer(factory, options, store, inbox);
35
69
  }
36
70
  else {
37
- serveStdio(factory, { onerror: (e) => logger.error(e.message) });
71
+ // STDIO: un solo usuario, el de la compu, con la misma sesión para todas las conexiones.
72
+ const aula = new LocalSession();
73
+ serveStdio(() => createServer(aula, { local: true }), { onerror: (e) => logger.error(e.message) });
38
74
  logger.info("Servidor MCP iniciado en STDIO");
39
75
  }
40
76
  }