utn-mcp 0.6.9 → 0.8.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.
@@ -1,20 +1,33 @@
1
- import { CookieJar } from "tough-cookie";
2
1
  import { loadCredentials } from "./credentials.js";
2
+ import { defaultRegional, findRegional } from "./regionals.js";
3
3
  import { fetchToken, LoginRequiredError } from "./sso-login.js";
4
4
  /** Con qué nombre guarda el aula su sesión en el store. */
5
5
  export const AULA_PLATFORM = "aula";
6
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.
7
+ * Sesión del modo HTTP: la de un usuario del store, en el aula de su regional. Renueva el token con la
8
+ * contraseña del keychain cuando es de este mismo usuario: eso pasa solo en local, porque el servidor
9
+ * hosteado no guarda contraseñas. Si no, el usuario vuelve a iniciar sesión.
10
10
  */
11
11
  export class StoredAulaSession {
12
12
  store;
13
13
  userId;
14
+ loginUrl;
14
15
  refreshing = null;
15
- constructor(store, userId) {
16
+ regional = null;
17
+ /** `loginUrl` es la página donde el usuario vuelve a iniciar sesión, si el servidor tiene una. */
18
+ constructor(store, userId, loginUrl) {
16
19
  this.store = store;
17
20
  this.userId = userId;
21
+ this.loginUrl = loginUrl;
22
+ }
23
+ /** La del usuario en el store; no cambia mientras dura la sesión, así que se lee una sola vez. */
24
+ getRegional() {
25
+ this.regional ??= this.store.findUser(this.userId).then((user) => {
26
+ if (!user)
27
+ throw this.loginRequired("No hay usuario para esta sesión.");
28
+ return findRegional(user.regional);
29
+ });
30
+ return this.regional;
18
31
  }
19
32
  async getToken() {
20
33
  return (await this.read()).token;
@@ -26,30 +39,31 @@ export class StoredAulaSession {
26
39
  return this.refreshing;
27
40
  }
28
41
  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.
42
+ const [stored, regional] = await Promise.all([this.read(), this.getRegional()]);
43
+ // Las del keychain (o UTN_USER/UTN_PASS) son de una sola persona: con otro usuario (o el mismo
44
+ // nombre en otra regional) entraríamos al aula de ella y guardaríamos su token en la sesión de este.
33
45
  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
+ const sameUser = credentials?.username.trim().toLowerCase() === stored.username &&
47
+ (credentials.regional ?? defaultRegional().id) === regional.id;
48
+ if (!credentials || !sameUser)
49
+ throw this.loginRequired("La sesión del aula venció.");
50
+ const token = await fetchToken(regional, credentials);
51
+ // Sin `...stored`: las sesiones guardadas antes traen cookies, que ya no se usan.
52
+ const data = { username: stored.username, token };
46
53
  await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
47
54
  return token;
48
55
  }
49
56
  async read() {
50
57
  const raw = await this.store.loadSession(this.userId, AULA_PLATFORM);
51
58
  if (!raw)
52
- throw new LoginRequiredError("No hay sesión del aula para este usuario: corré `utn-mcp login --http`.");
59
+ throw this.loginRequired("No hay sesión del aula para este usuario.");
53
60
  return JSON.parse(raw);
54
61
  }
62
+ /** Hosteado, el usuario vuelve a entrar por la página del servidor; sin ella, por el CLI. */
63
+ loginRequired(reason) {
64
+ const next = this.loginUrl
65
+ ? `Pedile al usuario que vuelva a iniciar sesión en ${this.loginUrl} y después reintentá.`
66
+ : "Volvé a iniciar sesión con `utn-mcp login --http` en la máquina del servidor.";
67
+ return new LoginRequiredError(`${reason} ${next}`);
68
+ }
55
69
  }
@@ -1,6 +1,5 @@
1
1
  import { textToHtml } from "../../shared/html.js";
2
2
  import { addDiscussion, addDiscussionPost, createUserEvent, deleteChoiceResponses, deleteChoicegroupResponses, deletePost, getChoiceOptions, getChoicegroupOptions, getDiscussionPosts, getMemberName, sendInstantMessage, submitChoiceResponse, submitChoicegroupResponse, updateDiscussionPost, } from "../client/client.js";
3
- import { AULA_BASE_URL } from "../client/constants.js";
4
3
  import { toOption } from "./activities.js";
5
4
  import { discussionAddr, formatAddress, parseAddress } from "./address.js";
6
5
  import { isoDate } from "./fields.js";
@@ -154,6 +153,6 @@ export async function addCalendarEvent(session, event) {
154
153
  inicio: isoDate(timestart),
155
154
  fin: timeduration ? isoDate(timestart + timeduration) : undefined,
156
155
  // Para que el usuario lo vea en el calendario del aula.
157
- url: `${AULA_BASE_URL}/calendar/view.php?view=day&time=${timestart}`,
156
+ url: `${(await session.getRegional()).baseUrl}/calendar/view.php?view=day&time=${timestart}`,
158
157
  };
159
158
  }
@@ -1,7 +1,5 @@
1
1
  import { openDocument, UnsupportedFormat } from "../../shared/documents/index.js";
2
- import { AULA_BASE_URL } from "../client/constants.js";
3
2
  import { fetchFile } from "../client/moodle-api.js";
4
- const ORIGIN = new URL(AULA_BASE_URL).origin;
5
3
  const WS_PATH = "/webservice/pluginfile.php/";
6
4
  const WEB_PATH = "/pluginfile.php/";
7
5
  /** Cuánto se devuelve por llamada, en caracteres de texto. */
@@ -13,16 +11,28 @@ const MAX_BYTES = 30 * 1024 * 1024;
13
11
  /**
14
12
  * Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
15
13
  * `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
16
- * sale nunca del servidor.
14
+ * sale nunca del servidor. `fileurl` viene de Moodle: ya es del aula del usuario.
17
15
  */
18
16
  export function userFacingUrl(fileurl) {
19
- return fileurl.replace(`${ORIGIN}${WS_PATH}`, `${ORIGIN}${WEB_PATH}`);
17
+ let u;
18
+ try {
19
+ u = new URL(fileurl);
20
+ }
21
+ catch {
22
+ return fileurl;
23
+ }
24
+ // Hay aulas en una subruta (`/moodle`): el tramo a cambiar puede no estar al principio.
25
+ const at = u.pathname.indexOf(WS_PATH);
26
+ if (at < 0)
27
+ return fileurl;
28
+ u.pathname = u.pathname.slice(0, at) + WEB_PATH + u.pathname.slice(at + WS_PATH.length);
29
+ return u.href;
20
30
  }
21
31
  /**
22
- * Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula: con
23
- * cualquier otra URL estaríamos mandando el token del usuario a donde diga el modelo.
32
+ * Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula del
33
+ * usuario (`baseUrl`): con cualquier otra URL estaríamos mandando su token a donde diga el modelo.
24
34
  */
25
- function downloadUrl(url) {
35
+ function downloadUrl(baseUrl, url) {
26
36
  let u;
27
37
  try {
28
38
  u = new URL(url);
@@ -30,10 +40,18 @@ function downloadUrl(url) {
30
40
  catch {
31
41
  throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
32
42
  }
33
- const path = u.pathname.startsWith(WS_PATH) ? u.pathname : u.pathname.startsWith(WEB_PATH) ? WS_PATH + u.pathname.slice(WEB_PATH.length) : null;
34
- if (u.origin !== ORIGIN || !path) {
35
- throw new Error(`Solo se pueden leer archivos del aula virtual (${ORIGIN}${WEB_PATH}...).`);
43
+ const root = new URL(baseUrl);
44
+ const prefix = root.pathname.replace(/\/+$/, "");
45
+ const rest = u.pathname.startsWith(prefix + WS_PATH)
46
+ ? u.pathname.slice(prefix.length + WS_PATH.length)
47
+ : u.pathname.startsWith(prefix + WEB_PATH)
48
+ ? u.pathname.slice(prefix.length + WEB_PATH.length)
49
+ : null;
50
+ // `origin` ignora `usuario@`: con eso `fetch` rechaza la URL y su error, con el token ya puesto, llega al modelo.
51
+ if (u.origin !== root.origin || u.username || u.password || rest === null) {
52
+ throw new Error(`Solo se pueden leer archivos del aula virtual (${root.origin}${prefix}${WEB_PATH}...).`);
36
53
  }
54
+ const path = prefix + WS_PATH + rest;
37
55
  u.pathname = path;
38
56
  u.searchParams.delete("token");
39
57
  u.searchParams.delete("wstoken");
@@ -41,7 +59,8 @@ function downloadUrl(url) {
41
59
  }
42
60
  /** Baja un archivo del aula y devuelve desde la parte `from` tantas como entren en el presupuesto. */
43
61
  export async function readAulaFile(session, url, from = 1) {
44
- const download = downloadUrl(url);
62
+ const { baseUrl } = await session.getRegional();
63
+ const download = downloadUrl(baseUrl, url);
45
64
  const fileName = decodeURIComponent(download.pathname.split("/").pop() ?? "");
46
65
  const forUser = userFacingUrl(download.href);
47
66
  const { bytes } = await fetchFile(session, download, MAX_BYTES);
@@ -1,33 +1,33 @@
1
- import { CookieJar } from "tough-cookie";
2
- import { ask, askHidden } from "../shared/input.js";
1
+ import { ask, askChoice, askHidden } from "../shared/input.js";
3
2
  import { logger } from "../shared/logger.js";
4
3
  import { SqliteStore } from "../shared/store/sqlite-store.js";
5
4
  import { saveCredentials } from "./client/credentials.js";
5
+ import { linkAulaAccount } from "./client/link.js";
6
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";
7
+ import { availableRegionals, defaultRegional } from "./client/regionals.js";
9
8
  /**
10
- * `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
9
+ * `utn-mcp login`: pide la regional, usuario y contraseña una vez, los guarda en el keychain
11
10
  * y obtiene el primer token. Después el servidor renueva solo.
12
11
  * Con `--http` guarda la sesión en el store y emite el token para el cliente MCP.
13
12
  */
14
13
  export async function runLogin(http) {
15
- const username = (await ask("Usuario institucional de la UTN: ")).trim();
14
+ const regional = await askRegional();
15
+ const username = (await ask("Usuario de la UTN: ")).trim();
16
16
  if (!username)
17
17
  throw new Error("Falta el usuario.");
18
18
  const password = await askHidden("Contraseña: ");
19
- const credentials = { username, password };
19
+ const credentials = { username, password, regional: regional.id };
20
20
  let accessToken = null;
21
21
  if (http)
22
- accessToken = await loginToStore(credentials);
22
+ accessToken = await loginToStore(regional, credentials);
23
23
  else
24
- await new LocalSession().login(credentials);
25
- // Se guardan recién después de comprobar que el SSO las aceptó.
24
+ await new LocalSession().login(regional, credentials);
25
+ // Se guardan recién después de comprobar que el aula las aceptó.
26
26
  if (await saveCredentials(credentials)) {
27
27
  logger.info("Credenciales guardadas en el keychain del sistema.");
28
28
  }
29
29
  else {
30
- logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER y UTN_PASS.");
30
+ logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER, UTN_PASS y UTN_REGIONAL.");
31
31
  }
32
32
  if (accessToken) {
33
33
  logger.info("Listo. Configurá tu cliente MCP con este header; no se vuelve a mostrar:\n\n" +
@@ -37,14 +37,21 @@ export async function runLogin(http) {
37
37
  logger.info("Listo: ya podés usar el servidor MCP.");
38
38
  }
39
39
  }
40
+ const SUPPORT_LABEL = {
41
+ verified: "",
42
+ candidate: " · sin probar todavía",
43
+ unsupported: " · todavía sin soporte",
44
+ };
45
+ /** Arranca marcada la de UTN_REGIONAL (o FRBA). */
46
+ async function askRegional() {
47
+ const regionals = availableRegionals();
48
+ const initial = regionals.findIndex((r) => r.id === defaultRegional().id);
49
+ const labels = regionals.map((r) => r.name + SUPPORT_LABEL[r.support]);
50
+ return regionals[await askChoice("Regional:", labels, Math.max(initial, 0))];
51
+ }
40
52
  /** 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);
53
+ async function loginToStore(regional, credentials) {
44
54
  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));
55
+ const { userId } = await linkAulaAccount(store, regional, credentials);
49
56
  return store.issueToken(userId, { kind: "access", clientId: "local" });
50
57
  }
@@ -17,12 +17,16 @@ const failure = (e) => ({
17
17
  const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
18
18
  export function registerAulaVirtualTools(server, session, files) {
19
19
  server.registerTool("aula_get_site_info", {
20
- description: "Datos del usuario logueado en el aula virtual de la UTN FRBA y del sitio Moodle.",
20
+ description: "Datos del usuario logueado y del sitio Moodle de su aula virtual. `regional` es la regional de la UTN " +
21
+ "de esa aula (por ejemplo, \"Tucumán (FRT)\"): usala para hablarle de su facultad.",
21
22
  annotations: READ_ONLY,
22
23
  }, async () => {
23
24
  try {
24
- const { sitename, fullname, username, userid, release } = await getSiteInfo(session);
25
- return json({ sitename, fullname, username, userid, release });
25
+ const [{ sitename, fullname, username, userid, release }, regional] = await Promise.all([
26
+ getSiteInfo(session),
27
+ session.getRegional(),
28
+ ]);
29
+ return json({ regional: regional.name, sitename, fullname, username, userid, release });
26
30
  }
27
31
  catch (e) {
28
32
  return failure(e);
@@ -271,7 +275,8 @@ export function registerAulaVirtualTools(server, session, files) {
271
275
  '`-F "archivos=@..."` por archivo, todos en el mismo comando. Responde JSON con lo recibido.\n' +
272
276
  "- Si no, pasale el `url` al usuario: se abre en el navegador, también en el celu, y elige " +
273
277
  "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. " +
278
+ "Subir al link no entrega nada. Cada link acepta una sola subida: si hay que cambiar los " +
279
+ "archivos, pedí otro link. " +
275
280
  "`acepta` trae los límites de la tarea.",
276
281
  inputSchema: z.object({
277
282
  direccion: z
@@ -299,7 +304,8 @@ export function registerAulaVirtualTools(server, session, files) {
299
304
  'Ej: ["C:\\Users\\ana\\Documents\\TP3.pdf"].'
300
305
  : "Lista final de los archivos que ya están en la entrega, por su `url` de aula_open; los " +
301
306
  "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"].'),
307
+ 'Ej: ["https://<aula>/pluginfile.php/123/assignsubmission_file/submission_files/456/TP3.pdf"], ' +
308
+ "con el dominio del aula tal como viene en la `url`."),
303
309
  texto: z
304
310
  .string()
305
311
  .optional()
package/build/http.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import express from "express";
2
2
  import { Readable } from "node:stream";
3
3
  import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, OAuthErrorCode, verifyBearerToken, } from "@modelcontextprotocol/server";
4
- import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
4
+ import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node";
5
+ import { mountAdmin } from "./oauth/admin.js";
6
+ import { cors, mountOAuth } from "./oauth/server.js";
5
7
  import { logger } from "./shared/logger.js";
8
+ import { escapeHtml, page } from "./shared/pages.js";
6
9
  import { MAX_UPLOAD_BYTES, formatSize } from "./shared/uploads.js";
7
10
  const asMiddleware = (guard) => (req, res, next) => {
8
11
  if (guard(req, res))
@@ -37,10 +40,34 @@ function storeVerifier(store) {
37
40
  },
38
41
  };
39
42
  }
43
+ const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
40
44
  /** 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) {
45
+ export function startHttpServer(factory, options, store, inbox) {
46
+ const { host, port, publicUrl, access, redirects, adminSecret } = options;
42
47
  const handler = createMcpHandler(factory, { onerror: (e) => logger.error(e.message) });
43
48
  const verifier = storeVerifier(store);
49
+ const app = express();
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
+ app.disable("x-powered-by");
53
+ app.use(securityHeaders(publicUrl?.startsWith("https://") ?? false));
54
+ // Contra DNS rebinding: solo se aceptan los hosts de esta máquina y el de la URL pública.
55
+ const allowed = publicUrl ? [...LOOPBACK, new URL(publicUrl).hostname] : LOOPBACK;
56
+ if (!publicUrl && !LOOPBACK.includes(host)) {
57
+ logger.warn(`Escuchando en ${host} sin UTN_MCP_PUBLIC_URL: se rechazan los requests que no lleguen como localhost.`);
58
+ }
59
+ app.use(asMiddleware(hostHeaderValidation(allowed)));
60
+ // Origin solo donde un navegador ajeno no tiene nada que hacer; los endpoints de OAuth tienen CORS abierto.
61
+ const sameOrigin = asMiddleware(originValidation(allowed));
62
+ const resourceMetadataUrl = mountOAuth(app, {
63
+ issuer: publicUrl ?? `http://127.0.0.1:${port}`,
64
+ store,
65
+ access,
66
+ sameOrigin,
67
+ redirects,
68
+ });
69
+ if (adminSecret)
70
+ mountAdmin(app, store, adminSecret);
44
71
  const requireToken = async (req, res, next) => {
45
72
  try {
46
73
  // toNodeHandler le pasa `req.auth` al handler como `authInfo`.
@@ -48,27 +75,34 @@ export function startHttpServer(factory, port, store, inbox) {
48
75
  next();
49
76
  }
50
77
  catch (e) {
51
- const challenge = bearerAuthChallengeResponse(e);
78
+ // El 401 dice dónde está la metadata: de ahí el cliente arranca el OAuth.
79
+ const challenge = bearerAuthChallengeResponse(e, { resourceMetadataUrl });
52
80
  res.status(challenge.status);
53
81
  challenge.headers.forEach((value, name) => res.setHeader(name, value));
54
82
  res.send(await challenge.text());
55
83
  }
56
84
  };
57
- const app = express();
58
- app.use(asMiddleware(localhostHostValidation()));
59
- app.use(asMiddleware(localhostOriginValidation()));
85
+ // Publicado, /mcp acepta cualquier origen: se autentica con el bearer, que el navegador no manda solo
86
+ // (no hay cookies), así que una página ajena no lo puede usar en nombre de nadie. Hay clientes que lo
87
+ // llaman desde su web, como Grok. En local sigue el chequeo de Origin, contra DNS rebinding.
88
+ const mcpOrigin = publicUrl ? cors : sameOrigin;
89
+ app.options("/mcp", mcpOrigin, (_req, res) => void res.sendStatus(204));
60
90
  // Sin express.json(): el handler lee el body por su cuenta.
61
- app.all("/mcp", requireToken, toNodeHandler(handler));
91
+ app.all("/mcp", mcpOrigin, requireToken, toNodeHandler(handler));
62
92
  // 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);
93
+ app.get("/subir/:id", sameOrigin, (req, res) => {
94
+ const upload = inbox.get(String(req.params.id));
65
95
  if (!upload)
66
96
  return void res.status(404).type("html").send(page("Link vencido", EXPIRED));
97
+ if (upload.files)
98
+ return void res.status(409).type("html").send(page("Link ya usado", USED));
67
99
  res.type("html").send(page(upload.title, uploadForm(upload.limits)));
68
100
  });
69
- app.post("/subir/:id", (req, res) => receiveUpload(inbox, req, res));
70
- app.listen(port, "127.0.0.1", () => {
71
- logger.info(`Servidor MCP escuchando en http://127.0.0.1:${port}/mcp`);
101
+ app.post("/subir/:id", sameOrigin, (req, res) => receiveUpload(inbox, req, res));
102
+ app.listen(port, host, () => {
103
+ logger.info(`Servidor MCP escuchando en http://${host}:${port}/mcp`);
104
+ if (publicUrl)
105
+ logger.info(`Publicado en ${publicUrl}/mcp`);
72
106
  });
73
107
  }
74
108
  /** Recibe el multipart del formulario o de `curl -F archivos=@...`. Al navegador le contesta HTML; al resto, JSON. */
@@ -78,17 +112,21 @@ async function receiveUpload(inbox, req, res) {
78
112
  ? res.status(status).type("html").send(page("No se pudo subir", `<p>${escapeHtml(message)}</p>`))
79
113
  : res.status(status).json({ error: message });
80
114
  const id = String(req.params.id);
81
- if (!inbox.get(id))
115
+ const upload = inbox.get(id);
116
+ if (!upload)
82
117
  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
- }
118
+ if (upload.files)
119
+ return void fail(409, "Este link ya se usó. Pedile al agente uno nuevo.");
120
+ const tooBig = `La subida pasa el máximo de ${formatSize(MAX_UPLOAD_BYTES)}.`;
121
+ if (Number(req.headers["content-length"]) > MAX_UPLOAD_BYTES)
122
+ return void fail(413, tooBig);
123
+ const body = limitBytes(Readable.toWeb(req), MAX_UPLOAD_BYTES);
86
124
  try {
87
125
  // Request de la API web: parsea multipart sin sumar dependencias.
88
126
  const request = new Request(`http://localhost${req.url}`, {
89
127
  method: "POST",
90
128
  headers: req.headers,
91
- body: Readable.toWeb(req),
129
+ body: body.stream,
92
130
  duplex: "half",
93
131
  });
94
132
  const form = await request.formData();
@@ -107,10 +145,44 @@ async function receiveUpload(inbox, req, res) {
107
145
  }
108
146
  }
109
147
  catch (e) {
110
- fail(400, e.message);
148
+ if (body.exceeded()) {
149
+ fail(413, tooBig);
150
+ req.destroy();
151
+ }
152
+ else {
153
+ fail(400, e.message);
154
+ }
111
155
  }
112
156
  }
157
+ function limitBytes(source, maxBytes) {
158
+ let total = 0;
159
+ const stream = source.pipeThrough(new TransformStream({
160
+ transform(chunk, controller) {
161
+ total += chunk.length;
162
+ if (total > maxBytes)
163
+ controller.error(new Error("La subida es demasiado grande."));
164
+ else
165
+ controller.enqueue(chunk);
166
+ },
167
+ }));
168
+ return { stream, exceeded: () => total > maxBytes };
169
+ }
170
+ function securityHeaders(https) {
171
+ return (_req, res, next) => {
172
+ res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'");
173
+ res.setHeader("X-Frame-Options", "DENY");
174
+ res.setHeader("X-Content-Type-Options", "nosniff");
175
+ // `same-origin` y no `no-referrer`: con `no-referrer` el navegador manda `Origin: null` en los POST de
176
+ // formularios, y el chequeo de Origin rechaza el login.
177
+ res.setHeader("Referrer-Policy", "same-origin");
178
+ // Un año, en segundos.
179
+ if (https)
180
+ res.setHeader("Strict-Transport-Security", "max-age=31536000");
181
+ next();
182
+ };
183
+ }
113
184
  const EXPIRED = "<p>Este link de subida venció o ya no existe. Pedile al agente uno nuevo.</p>";
185
+ const USED = "<p>Ya se subieron archivos a este link y no se pueden cambiar. Si te equivocaste, pedile al agente uno nuevo.</p>";
114
186
  function uploadForm(limits) {
115
187
  const rules = [
116
188
  limits.maxFiles ? `hasta ${limits.maxFiles} archivo(s)` : "",
@@ -124,14 +196,3 @@ function uploadForm(limits) {
124
196
  `<button type="submit">Subir</button></form>` +
125
197
  "<p class=nota>Subirlos no entrega la tarea: después el agente te confirma y la entrega.</p>");
126
198
  }
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
@@ -1,24 +1,32 @@
1
1
  #!/usr/bin/env node
2
+ import "./shared/load-env.js";
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 { defaultRegional } from "./aula-virtual/client/regionals.js";
5
7
  import { StoredAulaSession } from "./aula-virtual/client/stored-session.js";
6
8
  import { runLogin } from "./aula-virtual/login.js";
7
9
  import { startHttpServer, userIdOf } from "./http.js";
10
+ import { accessPolicyFromEnv } from "./oauth/access.js";
11
+ import { redirectAllowlistFromEnv } from "./oauth/redirects.js";
8
12
  import { createServer } from "./server.js";
9
13
  import { logger } from "./shared/logger.js";
10
14
  import { SqliteStore } from "./shared/store/sqlite-store.js";
11
15
  import { UploadInbox } from "./shared/uploads.js";
12
16
  const USAGE = `Uso:
13
17
  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)
18
+ utn-mcp --http [--host H] [--port N] servidor MCP por HTTP (por defecto 127.0.0.1:3000)
15
19
  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`;
20
+ utn-mcp login --http lo mismo, para el modo HTTP: guarda la sesión y emite el token del cliente
21
+
22
+ Si hay un .env en la carpeta actual, se lee al arrancar (ver .env.example).`;
17
23
  async function main() {
18
24
  const { values, positionals } = parseArgs({
19
25
  allowPositionals: true,
20
26
  options: {
21
27
  http: { type: "boolean", default: false },
28
+ // Por defecto solo desde esta máquina; hosteado, `0.0.0.0` detrás del reverse proxy.
29
+ host: { type: "string", default: process.env.HOST ?? "127.0.0.1" },
22
30
  port: { type: "string", default: process.env.PORT ?? "3000" },
23
31
  help: { type: "boolean", short: "h", default: false },
24
32
  },
@@ -27,6 +35,8 @@ async function main() {
27
35
  console.error(USAGE);
28
36
  return;
29
37
  }
38
+ // Una UTN_REGIONAL que no existe corta acá: si no, recién fallaría al primer login o al primer pedido.
39
+ defaultRegional();
30
40
  if (positionals[0] === "login") {
31
41
  await runLogin(values.http);
32
42
  return;
@@ -35,13 +45,26 @@ async function main() {
35
45
  // Cada request trae el token de un usuario del store y se arma con su sesión. Los archivos
36
46
  // llegan por links de subida: el servidor puede no estar en la compu de quien entrega.
37
47
  const port = Number(values.port);
48
+ const publicUrl = process.env.UTN_MCP_PUBLIC_URL?.replace(/\/+$/, "") || undefined;
49
+ const baseUrl = publicUrl ?? `http://127.0.0.1:${port}`;
50
+ // La página de `/login` del propio servidor, salvo que el que hostea prefiera mandar a otra.
51
+ const loginUrl = process.env.UTN_MCP_LOGIN_URL || `${baseUrl}/login`;
38
52
  const store = await SqliteStore.open();
39
- const inbox = new UploadInbox(process.env.UTN_MCP_PUBLIC_URL ?? `http://127.0.0.1:${port}`);
53
+ const inbox = new UploadInbox(baseUrl);
40
54
  const factory = (ctx) => {
41
55
  const userId = userIdOf(ctx.authInfo);
42
- return createServer(new StoredAulaSession(store, userId), { local: false, inbox, owner: String(userId) });
56
+ const aula = new StoredAulaSession(store, userId, loginUrl);
57
+ return createServer(aula, { local: false, inbox, owner: String(userId) });
43
58
  };
44
- startHttpServer(factory, port, store, inbox);
59
+ const options = {
60
+ host: values.host,
61
+ port,
62
+ publicUrl,
63
+ access: accessPolicyFromEnv(),
64
+ redirects: redirectAllowlistFromEnv(),
65
+ adminSecret: process.env.UTN_MCP_ADMIN_SECRET || undefined,
66
+ };
67
+ startHttpServer(factory, options, store, inbox);
45
68
  }
46
69
  else {
47
70
  // STDIO: un solo usuario, el de la compu, con la misma sesión para todas las conexiones.
@@ -0,0 +1,52 @@
1
+ import { logger } from "../shared/logger.js";
2
+ /** Cuánto esperamos la respuesta de UTN_MCP_ACCESS_URL, en milisegundos. */
3
+ const ACCESS_TIMEOUT_MS = 5_000;
4
+ /**
5
+ * La política sale del entorno: con UTN_MCP_ACCESS_URL decide otro servicio;
6
+ * con UTN_MCP_ALLOWED_EMAILS, una lista; sin ninguna, entra cualquiera con usuario de la UTN.
7
+ */
8
+ export function accessPolicyFromEnv(env = process.env) {
9
+ if (env.UTN_MCP_ACCESS_URL)
10
+ return remotePolicy(env.UTN_MCP_ACCESS_URL, env.UTN_MCP_ACCESS_SECRET);
11
+ if (env.UTN_MCP_ALLOWED_EMAILS)
12
+ return listPolicy(env.UTN_MCP_ALLOWED_EMAILS);
13
+ logger.warn("Sin UTN_MCP_ACCESS_URL ni UTN_MCP_ALLOWED_EMAILS: puede entrar cualquiera con usuario de la UTN.");
14
+ return async () => ({ allowed: true });
15
+ }
16
+ /** Emails o dominios (`@frba.utn.edu.ar`), separados por coma. */
17
+ function listPolicy(raw) {
18
+ const entries = raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
19
+ return async ({ email }) => {
20
+ const own = email?.toLowerCase();
21
+ if (own && entries.some((e) => (e.startsWith("@") ? own.endsWith(e) : own === e)))
22
+ return { allowed: true };
23
+ return { allowed: false, reason: "Tu cuenta no está habilitada en este servidor. Pedile acceso a quien lo administra." };
24
+ };
25
+ }
26
+ /** `POST url` con `{ email, usuario, regional }`; contesta `{ permitido, motivo?, url? }`. */
27
+ function remotePolicy(url, secret) {
28
+ if (!secret)
29
+ logger.warn("UTN_MCP_ACCESS_URL sin UTN_MCP_ACCESS_SECRET: el pedido de acceso va sin autenticar.");
30
+ return async ({ regional, username, email }) => {
31
+ if (!email)
32
+ return { allowed: false, reason: "El aula no devolvió tu email, y sin él no podemos verificar tu acceso." };
33
+ try {
34
+ const res = await fetch(url, {
35
+ method: "POST",
36
+ headers: { "content-type": "application/json", ...(secret ? { authorization: `Bearer ${secret}` } : {}) },
37
+ body: JSON.stringify({ email, usuario: username, regional }),
38
+ signal: AbortSignal.timeout(ACCESS_TIMEOUT_MS),
39
+ });
40
+ if (!res.ok)
41
+ throw new Error(`HTTP ${res.status}`);
42
+ const data = (await res.json());
43
+ if (data.permitido === true)
44
+ return { allowed: true };
45
+ return { allowed: false, reason: data.motivo || "Tu cuenta no está habilitada en este servidor.", url: data.url };
46
+ }
47
+ catch (e) {
48
+ logger.error(`No se pudo consultar el acceso de ${email}: ${e.message}`);
49
+ return { allowed: false, reason: "No pudimos verificar si tenés acceso: probá de nuevo en unos minutos." };
50
+ }
51
+ };
52
+ }