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.
@@ -0,0 +1,110 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import express from "express";
3
+ import { linkAulaAccount } from "../aula-virtual/client/link.js";
4
+ import { availableRegionals } from "../aula-virtual/client/regionals.js";
5
+ import { InvalidCredentialsError } from "../aula-virtual/client/sso-login.js";
6
+ import { AULA_PLATFORM } from "../aula-virtual/client/stored-session.js";
7
+ import { logger } from "../shared/logger.js";
8
+ import { RateLimiter } from "./expiring.js";
9
+ /** Intentos de `/admin/verificar` por cuenta del aula cada 15 minutos: que no sirva para probar contraseñas. */
10
+ const VERIFY_ATTEMPTS = 5;
11
+ const VERIFY_WINDOW_MS = 15 * 60_000;
12
+ const SUPPORT = { verified: "verificada", candidate: "candidata", unsupported: "sin_soporte" };
13
+ /** Segundos desde epoch, como los guarda el store, a ISO. */
14
+ const isoDate = (seconds) => new Date(seconds * 1000).toISOString();
15
+ /**
16
+ * API para que otro servicio del que hostea (un panel de cuenta, por ejemplo) liste usuarios, muestre y
17
+ * desconecte sus apps, consulte las regionales y dé de alta cuentas vinculando su aula. Solo
18
+ * existe con UTN_MCP_ADMIN_SECRET, que va como `Authorization: Bearer`. Los usuarios se buscan por email.
19
+ */
20
+ export function mountAdmin(app, store, secret) {
21
+ const json = express.json({ limit: "4kb" });
22
+ const verifyLimiter = new RateLimiter(VERIFY_ATTEMPTS, VERIFY_WINDOW_MS);
23
+ const requireSecret = (req, res, next) => {
24
+ const got = Buffer.from(req.headers.authorization ?? "");
25
+ const want = Buffer.from(`Bearer ${secret}`);
26
+ if (got.length === want.length && timingSafeEqual(got, want))
27
+ return next();
28
+ res.status(401).json({ error: "No autorizado." });
29
+ };
30
+ /** El usuario, su regional, cuándo se renovó su sesión del aula y sus apps conectadas. */
31
+ async function summary(user) {
32
+ const [clients, aulaAt] = await Promise.all([
33
+ store.listConnectedClients(user.id),
34
+ store.sessionUpdatedAt(user.id, AULA_PLATFORM),
35
+ ]);
36
+ return {
37
+ usuario: user.utnUsername,
38
+ regional: user.regional,
39
+ sesionAula: aulaAt ? isoDate(aulaAt) : undefined,
40
+ clientes: clients.map((c) => ({
41
+ id: c.clientId,
42
+ nombre: c.clientName ?? (c.clientId === "local" ? "Token de utn-mcp login" : "App sin nombre"),
43
+ ultimoUso: isoDate(c.lastTokenAt),
44
+ })),
45
+ };
46
+ }
47
+ /** `{ email }` → el resumen de ese usuario. */
48
+ app.post("/admin/clientes", requireSecret, json, async (req, res) => {
49
+ const email = typeof req.body?.email === "string" ? req.body.email : "";
50
+ const user = email ? await store.findUserByEmail(email) : null;
51
+ res.json(user ? await summary(user) : { clientes: [] });
52
+ });
53
+ /** Todos los usuarios con su resumen, para el panel de quien administra el servidor. */
54
+ app.post("/admin/usuarios", requireSecret, async (_req, res) => {
55
+ const users = await store.listUsers();
56
+ const usuarios = await Promise.all(users.map(async (u) => ({ email: u.email || undefined, alta: isoDate(u.createdAt), ...(await summary(u)) })));
57
+ res.json({ usuarios });
58
+ });
59
+ /** `{ email, cliente }` → borra los tokens de esa app: tiene que volver a conectarse. */
60
+ app.post("/admin/clientes/revocar", requireSecret, json, async (req, res) => {
61
+ const email = typeof req.body?.email === "string" ? req.body.email : "";
62
+ const clientId = typeof req.body?.cliente === "string" ? req.body.cliente : "";
63
+ const user = email ? await store.findUserByEmail(email) : null;
64
+ if (!user || !clientId)
65
+ return void res.status(404).json({ error: "No existe ese usuario o esa app." });
66
+ await store.revokeClient(user.id, clientId);
67
+ res.json({ revocado: true });
68
+ });
69
+ /** Las regionales que se pueden elegir, con su soporte en este servidor. */
70
+ app.post("/admin/regionales", requireSecret, async (_req, res) => {
71
+ const regionales = availableRegionals(await store.listVerifiedRegionals()).map((r) => ({
72
+ id: r.id,
73
+ nombre: r.name,
74
+ soporte: SUPPORT[r.support],
75
+ }));
76
+ res.json({ regionales });
77
+ });
78
+ /**
79
+ * `{ regional, usuario, contrasena }` → entra al aula como el login de OAuth: crea el usuario si no
80
+ * existe y le guarda la sesión, así ya puede usar las tools. Devuelve `{ usuario, nombre, email }`; si
81
+ * no entra, `{ error, motivo }`.
82
+ */
83
+ app.post("/admin/verificar", requireSecret, json, async (req, res) => {
84
+ const id = typeof req.body?.regional === "string" ? req.body.regional : "";
85
+ const username = typeof req.body?.usuario === "string" ? req.body.usuario.trim() : "";
86
+ const password = typeof req.body?.contrasena === "string" ? req.body.contrasena : "";
87
+ const regional = availableRegionals(await store.listVerifiedRegionals()).find((r) => r.id === id);
88
+ if (!regional || !username || !password) {
89
+ return void res.status(400).json({ error: "pedido", motivo: "Faltan la regional, el usuario o la contraseña." });
90
+ }
91
+ if (regional.support === "unsupported") {
92
+ return void res.status(422).json({ error: "sin_soporte", motivo: `Todavía no hay soporte para ${regional.name}.` });
93
+ }
94
+ if (!verifyLimiter.allow(`${regional.id}:${username.toLowerCase()}`)) {
95
+ return void res.status(429).json({ error: "intentos", motivo: "Demasiados intentos. Esperá unos minutos." });
96
+ }
97
+ try {
98
+ const account = await linkAulaAccount(store, regional, { username, password });
99
+ logger.info(`Admin: ${account.username} (${regional.id}) vinculó su aula.`);
100
+ res.json({ usuario: account.username, nombre: account.fullname, email: account.email ?? undefined });
101
+ }
102
+ catch (e) {
103
+ if (e instanceof InvalidCredentialsError) {
104
+ return void res.status(401).json({ error: "credenciales", motivo: "El aula rechazó el usuario o la contraseña." });
105
+ }
106
+ logger.warn(`Verificación de ${username} (${regional.id}): ${e.message}`);
107
+ res.status(502).json({ error: "aula", motivo: `No pudimos entrar al aula de ${regional.name}.` });
108
+ }
109
+ });
110
+ }
@@ -0,0 +1,57 @@
1
+ import { randomBytes } from "node:crypto";
2
+ /**
3
+ * Valores que viven en memoria hasta que se usan o vencen: los pedidos de `/authorize` mientras el
4
+ * usuario completa el login y los codes hasta que el cliente los canjea. Si el servidor se reinicia,
5
+ * se pierden y el cliente vuelve a empezar el OAuth, que es lo que haría igual con un code vencido.
6
+ */
7
+ export class Expiring {
8
+ ttlMs;
9
+ items = new Map();
10
+ /** `ttlMs` en milisegundos. */
11
+ constructor(ttlMs) {
12
+ this.ttlMs = ttlMs;
13
+ }
14
+ add(value) {
15
+ this.sweep();
16
+ const id = randomBytes(24).toString("base64url");
17
+ this.items.set(id, { value, expiresAt: Date.now() + this.ttlMs });
18
+ return id;
19
+ }
20
+ get(id) {
21
+ this.sweep();
22
+ return this.items.get(id)?.value;
23
+ }
24
+ /** Lo devuelve y lo borra: un code se canjea una sola vez. */
25
+ take(id) {
26
+ const value = this.get(id);
27
+ this.items.delete(id);
28
+ return value;
29
+ }
30
+ sweep() {
31
+ const now = Date.now();
32
+ for (const [id, item] of this.items)
33
+ if (item.expiresAt <= now)
34
+ this.items.delete(id);
35
+ }
36
+ }
37
+ /** Cuenta intentos por clave (la IP) en una ventana fija. */
38
+ export class RateLimiter {
39
+ max;
40
+ windowMs;
41
+ hits = new Map();
42
+ /** `windowMs` en milisegundos. */
43
+ constructor(max, windowMs) {
44
+ this.max = max;
45
+ this.windowMs = windowMs;
46
+ }
47
+ allow(key) {
48
+ const now = Date.now();
49
+ for (const [k, hit] of this.hits)
50
+ if (hit.resetAt <= now)
51
+ this.hits.delete(k);
52
+ const hit = this.hits.get(key) ?? { count: 0, resetAt: now + this.windowMs };
53
+ hit.count++;
54
+ this.hits.set(key, hit);
55
+ return hit.count <= this.max;
56
+ }
57
+ }
@@ -0,0 +1,60 @@
1
+ /** Los callbacks de los clientes MCP conocidos (Claude, ChatGPT, Grok, Gemini, VS Code web) y los esquemas de editores. */
2
+ const DEFAULT_HOSTS = [
3
+ "claude.ai",
4
+ "claude.com",
5
+ "chatgpt.com",
6
+ "chat.openai.com",
7
+ "grok.com",
8
+ "gemini.google.com",
9
+ "vscode.dev",
10
+ "insiders.vscode.dev",
11
+ ];
12
+ const DEFAULT_SCHEMES = ["cursor:", "vscode:", "vscode-insiders:", "windsurf:"];
13
+ /** En loopback la redirección queda en la compu del usuario: ahí escuchan los clientes de escritorio. */
14
+ const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
15
+ /**
16
+ * Los conocidos más los de UTN_MCP_REDIRECT_ALLOW, separados por coma: hosts (`app.ejemplo.com`)
17
+ * o esquemas terminados en dos puntos (`miapp:`).
18
+ */
19
+ export function redirectAllowlistFromEnv(env = process.env) {
20
+ const extra = (env.UTN_MCP_REDIRECT_ALLOW ?? "").split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
21
+ return {
22
+ hosts: new Set([...DEFAULT_HOSTS, ...extra.filter((e) => !e.endsWith(":"))]),
23
+ schemes: new Set([...DEFAULT_SCHEMES, ...extra.filter((e) => e.endsWith(":"))]),
24
+ };
25
+ }
26
+ /**
27
+ * `trusted`: loopback, un host HTTPS de la lista o el esquema de una app de la lista.
28
+ * `unknown`: cualquier otro host HTTPS, como el de un agente propio; se acepta con aviso.
29
+ * `rejected`: el resto.
30
+ */
31
+ export function redirectTrust(uri, allow) {
32
+ if (typeof uri !== "string")
33
+ return "rejected";
34
+ let url;
35
+ try {
36
+ url = new URL(uri);
37
+ }
38
+ catch {
39
+ return "rejected";
40
+ }
41
+ if (url.hash || url.username || url.password)
42
+ return "rejected";
43
+ if (url.protocol === "http:" || url.protocol === "https:") {
44
+ if (LOOPBACK.includes(url.hostname))
45
+ return "trusted";
46
+ if (url.protocol === "http:")
47
+ return "rejected";
48
+ return allow.hosts.has(url.hostname) ? "trusted" : "unknown";
49
+ }
50
+ return allow.schemes.has(url.protocol) ? "trusted" : "rejected";
51
+ }
52
+ /** Cómo se le nombra al usuario el destino. */
53
+ export function describeRedirect(uri) {
54
+ const url = new URL(uri);
55
+ if (LOOPBACK.includes(url.hostname))
56
+ return "una app en tu compu";
57
+ if (url.protocol === "https:")
58
+ return url.hostname;
59
+ return `la app ${url.protocol.slice(0, -1)}`;
60
+ }
@@ -0,0 +1,341 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import express from "express";
3
+ import { buildOAuthProtectedResourceMetadata, checkResourceAllowed, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
4
+ import { linkAulaAccount } from "../aula-virtual/client/link.js";
5
+ import { availableRegionals, defaultRegional } from "../aula-virtual/client/regionals.js";
6
+ import { InvalidCredentialsError } from "../aula-virtual/client/sso-login.js";
7
+ import { logger } from "../shared/logger.js";
8
+ import { escapeHtml, page } from "../shared/pages.js";
9
+ import { Expiring, RateLimiter } from "./expiring.js";
10
+ import { describeRedirect, redirectTrust } from "./redirects.js";
11
+ /** Duración del access token, en segundos: una baja de acceso se aplica a más tardar en este tiempo. */
12
+ const ACCESS_TTL = 60 * 60;
13
+ /** Duración del refresh token, en segundos. Rota en cada uso. */
14
+ const REFRESH_TTL = 30 * 24 * 60 * 60;
15
+ /** Cuánto vive un code sin canjear, en milisegundos. */
16
+ const CODE_TTL_MS = 60_000;
17
+ /** Cuánto tiene el usuario para completar el login, en milisegundos. */
18
+ const PENDING_TTL_MS = 10 * 60_000;
19
+ /** Intentos de login por IP cada 15 minutos: que el servidor no sirva para probar contraseñas contra el SSO. */
20
+ const LOGIN_ATTEMPTS = 10;
21
+ const LOGIN_WINDOW_MS = 15 * 60_000;
22
+ /** Registros de clientes por IP cada hora. */
23
+ const REGISTER_ATTEMPTS = 20;
24
+ const REGISTER_WINDOW_MS = 60 * 60_000;
25
+ /**
26
+ * Servidor de autorización OAuth 2.1 para los clientes MCP: registro dinámico, PKCE obligatorio y
27
+ * clientes públicos. El usuario se identifica con la UTN, y ese mismo login vincula su aula: la
28
+ * contraseña se usa una vez y no se guarda. Devuelve la URL de la metadata del recurso, que va en el 401.
29
+ */
30
+ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }) {
31
+ const resource = `${issuer}/mcp`;
32
+ const metadata = {
33
+ issuer,
34
+ authorization_endpoint: `${issuer}/authorize`,
35
+ token_endpoint: `${issuer}/token`,
36
+ registration_endpoint: `${issuer}/register`,
37
+ revocation_endpoint: `${issuer}/revoke`,
38
+ response_types_supported: ["code"],
39
+ grant_types_supported: ["authorization_code", "refresh_token"],
40
+ code_challenge_methods_supported: ["S256"],
41
+ token_endpoint_auth_methods_supported: ["none"],
42
+ revocation_endpoint_auth_methods_supported: ["none"],
43
+ };
44
+ // Valida el issuer al arrancar (HTTPS salvo en localhost) en lugar de en el primer request.
45
+ const resourceMetadata = buildOAuthProtectedResourceMetadata({
46
+ oauthMetadata: metadata,
47
+ resourceServerUrl: new URL(resource),
48
+ resourceName: "UTN-MCP",
49
+ });
50
+ const pending = new Expiring(PENDING_TTL_MS);
51
+ const codes = new Expiring(CODE_TTL_MS);
52
+ const limiter = new RateLimiter(LOGIN_ATTEMPTS, LOGIN_WINDOW_MS);
53
+ const registerLimiter = new RateLimiter(REGISTER_ATTEMPTS, REGISTER_WINDOW_MS);
54
+ const form = express.urlencoded({ extended: false, limit: "16kb" });
55
+ const regionals = async () => availableRegionals(await store.listVerifiedRegionals());
56
+ app.get("/.well-known/oauth-authorization-server", cors, (_req, res) => void res.json(metadata));
57
+ app.get(["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"], cors, (_req, res) => void res.json(resourceMetadata));
58
+ app.options(["/register", "/token", "/revoke"], cors, (_req, res) => void res.sendStatus(204));
59
+ app.post("/register", cors, express.json({ limit: "16kb" }), async (req, res) => {
60
+ if (!registerLimiter.allow(req.ip ?? "desconocida")) {
61
+ return void oauthError(res, 429, "too_many_requests", "Demasiados registros desde esta IP. Probá en una hora.");
62
+ }
63
+ const body = (req.body ?? {});
64
+ const uris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
65
+ if (!uris.length || uris.some((u) => redirectTrust(u, redirects) === "rejected")) {
66
+ // Queda en el log para poder habilitar el esquema de una app nueva con UTN_MCP_REDIRECT_ALLOW.
67
+ logger.warn(`OAuth /register rechazado: ${JSON.stringify(uris).slice(0, 300)}`);
68
+ return void oauthError(res, 400, "invalid_redirect_uri", "Las redirect_uri tienen que ser HTTPS, de loopback o de una app habilitada en este servidor " +
69
+ "(UTN_MCP_REDIRECT_ALLOW), y sin fragmento.");
70
+ }
71
+ const client = {
72
+ clientId: randomUUID(),
73
+ clientName: typeof body.client_name === "string" ? body.client_name.slice(0, 100) : null,
74
+ redirectUris: uris,
75
+ };
76
+ await store.saveClient(client);
77
+ res.status(201).json({
78
+ client_id: client.clientId,
79
+ client_id_issued_at: Math.floor(Date.now() / 1000),
80
+ client_name: client.clientName ?? undefined,
81
+ redirect_uris: client.redirectUris,
82
+ grant_types: ["authorization_code", "refresh_token"],
83
+ response_types: ["code"],
84
+ token_endpoint_auth_method: "none",
85
+ });
86
+ });
87
+ app.get("/authorize", async (req, res) => {
88
+ const q = req.query;
89
+ const client = q.client_id ? await store.findClient(q.client_id) : null;
90
+ // Sin cliente o redirect_uri válidos no se redirige a ningún lado: el error lo ve el usuario.
91
+ if (!client)
92
+ return void errorPage(res, 400, "Cliente desconocido", "Este cliente no está registrado. Volvé a conectarlo desde tu app.");
93
+ if (!q.redirect_uri || !client.redirectUris.includes(q.redirect_uri)) {
94
+ return void errorPage(res, 400, "Redirección inválida", "La redirect_uri no es la que registró el cliente.");
95
+ }
96
+ const trust = redirectTrust(q.redirect_uri, redirects);
97
+ if (trust === "rejected") {
98
+ return void errorPage(res, 400, "App no habilitada", "Esta app vuelve a una dirección que este servidor no acepta.");
99
+ }
100
+ const fail = (error, description) => redirectWith(res, q.redirect_uri, { error, error_description: description, state: q.state });
101
+ if (q.response_type !== "code")
102
+ return void fail("unsupported_response_type", "Solo se acepta response_type=code.");
103
+ if (!q.code_challenge || q.code_challenge_method !== "S256") {
104
+ return void fail("invalid_request", "PKCE es obligatorio, con code_challenge_method=S256.");
105
+ }
106
+ if (q.resource && !checkResourceAllowed({ requestedResource: q.resource, configuredResource: resource })) {
107
+ return void fail("invalid_target", `El único recurso de este servidor es ${resource}.`);
108
+ }
109
+ const id = pending.add({
110
+ clientId: client.clientId,
111
+ clientName: client.clientName,
112
+ redirectUri: q.redirect_uri,
113
+ unknownRedirect: trust === "unknown",
114
+ codeChallenge: q.code_challenge,
115
+ state: q.state,
116
+ resource: q.resource,
117
+ });
118
+ res.type("html").send(loginPage({
119
+ action: "/authorize",
120
+ regionals: await regionals(),
121
+ pendingId: id,
122
+ clientName: client.clientName,
123
+ redirectUri: q.redirect_uri,
124
+ unknownRedirect: trust === "unknown",
125
+ }));
126
+ });
127
+ app.post("/authorize", sameOrigin, form, async (req, res) => {
128
+ const id = String(req.body?.pedido ?? "");
129
+ const request = pending.get(id);
130
+ if (!request)
131
+ return void errorPage(res, 400, "Pedido vencido", "Pasó demasiado tiempo. Volvé a conectar desde tu app.");
132
+ const list = await regionals();
133
+ const retry = (error) => res.type("html").send(loginPage({
134
+ action: "/authorize",
135
+ regionals: list,
136
+ pendingId: id,
137
+ clientName: request.clientName,
138
+ redirectUri: request.redirectUri,
139
+ unknownRedirect: request.unknownRedirect,
140
+ regional: req.body?.regional,
141
+ error,
142
+ }));
143
+ if (request.unknownRedirect && req.body?.acepto !== "si") {
144
+ return void retry("Para conectar una app que no es un cliente conocido tenés que aceptar el aviso.");
145
+ }
146
+ const account = await login(req, list, retry);
147
+ if (!account)
148
+ return;
149
+ const decision = await access({ regional: account.regional.id, username: account.username, email: account.email });
150
+ if (!decision.allowed)
151
+ return void deniedPage(res, decision.reason, decision.url);
152
+ pending.take(id);
153
+ const code = codes.add({
154
+ userId: account.userId,
155
+ clientId: request.clientId,
156
+ redirectUri: request.redirectUri,
157
+ codeChallenge: request.codeChallenge,
158
+ });
159
+ logger.info(`OAuth: ${account.username} (${account.regional.id}) autorizó a ${request.clientName ?? request.clientId}.`);
160
+ redirectWith(res, request.redirectUri, { code, state: request.state, iss: issuer });
161
+ });
162
+ app.post("/token", cors, form, express.json({ limit: "16kb" }), async (req, res) => {
163
+ const body = (req.body ?? {});
164
+ try {
165
+ if (body.grant_type === "authorization_code") {
166
+ const code = body.code ? codes.take(body.code) : undefined;
167
+ if (!code)
168
+ return void oauthError(res, 400, "invalid_grant", "El code no existe, venció o ya se usó.");
169
+ if (body.client_id && body.client_id !== code.clientId) {
170
+ return void oauthError(res, 400, "invalid_grant", "El code es de otro cliente.");
171
+ }
172
+ if (body.redirect_uri && body.redirect_uri !== code.redirectUri) {
173
+ return void oauthError(res, 400, "invalid_grant", "La redirect_uri no coincide con la del pedido.");
174
+ }
175
+ if (!body.code_verifier || pkceChallenge(body.code_verifier) !== code.codeChallenge) {
176
+ return void oauthError(res, 400, "invalid_grant", "El code_verifier no corresponde al code_challenge.");
177
+ }
178
+ return void res.json(await issueTokens(store, code.userId, code.clientId));
179
+ }
180
+ if (body.grant_type === "refresh_token") {
181
+ const found = body.refresh_token ? await store.findToken(body.refresh_token, "refresh") : null;
182
+ if (!found)
183
+ return void oauthError(res, 400, "invalid_grant", "El refresh token no existe, venció o fue revocado.");
184
+ const clientId = found.clientId ?? body.client_id ?? "desconocido";
185
+ if (body.client_id && found.clientId && body.client_id !== found.clientId) {
186
+ return void oauthError(res, 400, "invalid_grant", "El refresh token es de otro cliente.");
187
+ }
188
+ await store.revokeToken(body.refresh_token);
189
+ // El acceso se vuelve a mirar en cada refresh: si la cuenta se dio de baja, acá se corta.
190
+ const user = await store.findUser(found.userId);
191
+ if (!user)
192
+ return void oauthError(res, 400, "invalid_grant", "El usuario ya no existe.");
193
+ const decision = await access({ regional: user.regional, username: user.utnUsername, email: user.email });
194
+ if (!decision.allowed)
195
+ return void oauthError(res, 400, "invalid_grant", decision.reason);
196
+ return void res.json(await issueTokens(store, user.id, clientId));
197
+ }
198
+ oauthError(res, 400, "unsupported_grant_type", "Solo authorization_code y refresh_token.");
199
+ }
200
+ catch (e) {
201
+ logger.error(`OAuth /token: ${e.message}`);
202
+ oauthError(res, 500, "server_error", "Error interno al emitir el token.");
203
+ }
204
+ });
205
+ app.post("/revoke", cors, form, express.json({ limit: "16kb" }), async (req, res) => {
206
+ const token = req.body?.token;
207
+ if (token)
208
+ await store.revokeToken(token);
209
+ // RFC 7009: se contesta 200 aunque el token no exista.
210
+ res.sendStatus(200);
211
+ });
212
+ // Volver a iniciar sesión cuando vence la sesión del aula: no emite tokens, solo renueva la sesión.
213
+ app.get("/login", async (_req, res) => void res.type("html").send(loginPage({ action: "/login", regionals: await regionals() })));
214
+ app.post("/login", sameOrigin, form, async (req, res) => {
215
+ const list = await regionals();
216
+ const account = await login(req, list, (error) => res.type("html").send(loginPage({
217
+ action: "/login",
218
+ regionals: list,
219
+ regional: req.body?.regional,
220
+ error,
221
+ })));
222
+ if (!account)
223
+ return;
224
+ logger.info(`OAuth: ${account.username} (${account.regional.id}) renovó su sesión del aula.`);
225
+ res.type("html").send(page("Listo", "<p>Ya renovamos tu sesión del aula. Volvé al chat y pedile al agente que reintente.</p>"));
226
+ });
227
+ /** Entra al aula con lo que mandó el formulario; si falla, vuelve a mostrarlo con el error. */
228
+ async function login(req, list, retry) {
229
+ if (!limiter.allow(req.ip ?? "desconocida")) {
230
+ retry("Demasiados intentos. Esperá unos minutos y probá de nuevo.");
231
+ return null;
232
+ }
233
+ const username = String(req.body?.usuario ?? "").trim();
234
+ const password = String(req.body?.contrasena ?? "");
235
+ if (!username || !password) {
236
+ retry("Completá usuario y contraseña.");
237
+ return null;
238
+ }
239
+ const regional = list.find((r) => r.id === String(req.body?.regional ?? ""));
240
+ if (!regional) {
241
+ retry("Elegí tu regional de la lista.");
242
+ return null;
243
+ }
244
+ if (regional.support === "unsupported") {
245
+ retry(`Todavía no hay soporte para ${regional.name}: su aula todavía no funciona con UTN-MCP.`);
246
+ return null;
247
+ }
248
+ try {
249
+ return await linkAulaAccount(store, regional, { username, password });
250
+ }
251
+ catch (e) {
252
+ if (e instanceof InvalidCredentialsError)
253
+ retry("La UTN rechazó el usuario o la contraseña.");
254
+ else if (regional.support === "candidate") {
255
+ // Nadie la había usado acá: lo más probable es que todavía no ande, no que esté caída.
256
+ logger.warn(`Regional ${regional.id} sin verificar, falló el login de ${username}: ${e.message}`);
257
+ retry(`No pudimos usar el aula de ${regional.name}: puede que todavía no ande con UTN-MCP.`);
258
+ }
259
+ else {
260
+ logger.error(`Login de ${username} (${regional.id}): ${e.message}`);
261
+ retry("No pudimos entrar al aula. Puede que esté caída: probá de nuevo en unos minutos.");
262
+ }
263
+ return null;
264
+ }
265
+ }
266
+ return getOAuthProtectedResourceMetadataUrl(new URL(resource));
267
+ }
268
+ async function issueTokens(store, userId, clientId) {
269
+ const accessToken = await store.issueToken(userId, { kind: "access", clientId, ttlSeconds: ACCESS_TTL });
270
+ const refreshToken = await store.issueToken(userId, { kind: "refresh", clientId, ttlSeconds: REFRESH_TTL });
271
+ return { access_token: accessToken, token_type: "Bearer", expires_in: ACCESS_TTL, refresh_token: refreshToken };
272
+ }
273
+ const pkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
274
+ /** Los endpoints que llaman los clientes desde el navegador (como el MCP Inspector) aceptan cualquier origen. */
275
+ export const cors = (_req, res, next) => {
276
+ res.setHeader("Access-Control-Allow-Origin", "*");
277
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
278
+ res.setHeader("Access-Control-Allow-Headers", "content-type, authorization, mcp-protocol-version, mcp-session-id, last-event-id");
279
+ // Sin exponerlos, el navegador no deja leer el 401 que arranca el OAuth ni la sesión del transporte.
280
+ res.setHeader("Access-Control-Expose-Headers", "www-authenticate, mcp-session-id");
281
+ // Un día, en segundos: el navegador no repite el preflight en cada request.
282
+ res.setHeader("Access-Control-Max-Age", "86400");
283
+ next();
284
+ };
285
+ function redirectWith(res, base, params) {
286
+ const url = new URL(base);
287
+ for (const [k, v] of Object.entries(params))
288
+ if (v !== undefined)
289
+ url.searchParams.set(k, v);
290
+ res.redirect(302, url.toString());
291
+ }
292
+ function oauthError(res, status, error, description) {
293
+ res.status(status).json({ error, error_description: description });
294
+ }
295
+ function errorPage(res, status, title, message) {
296
+ res.status(status).type("html").send(page(title, `<p>${escapeHtml(message)}</p>`));
297
+ }
298
+ function deniedPage(res, reason, url) {
299
+ const link = url ? `<p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>` : "";
300
+ res.status(403).type("html").send(page("Sin acceso", `<p>${escapeHtml(reason)}</p>${link}`));
301
+ }
302
+ function loginPage(o) {
303
+ const where = o.redirectUri ? `<strong>${escapeHtml(describeRedirect(o.redirectUri))}</strong>` : "";
304
+ const destination = where && !o.unknownRedirect ? ` Al terminar vas a volver a ${where}: si no es la app que estás conectando, no sigas.` : "";
305
+ const warning = o.unknownRedirect
306
+ ? `<div class=aviso><p><strong>Esta app no es un cliente conocido.</strong> Al terminar vas a volver a ${where}, ` +
307
+ "y desde ahí van a poder usar tu aula en tu nombre: leer tus cursos y mensajes, entregar tareas y escribir " +
308
+ "mensajes. Si alguien te pasó este link y no estás conectando un agente tuyo o de alguien en quien confiás, " +
309
+ "no sigas: puede ser un intento de robarte la sesión.</p></div>"
310
+ : "";
311
+ const consent = o.unknownRedirect
312
+ ? `<label class=acepto><input type="checkbox" name="acepto" value="si" required><span>Entiendo el riesgo y ` +
313
+ `conecto esta app bajo mi responsabilidad. Quien administra este servidor no se hace responsable de lo que ` +
314
+ `${where} haga con mi aula ni con mi sesión.</span></label>`
315
+ : "";
316
+ const intro = o.pendingId
317
+ ? `<p>${escapeHtml(o.clientName ?? "Una app")} quiere usar UTN-MCP con tu Aula Virtual. Entrá con tu usuario de la UTN para conectarla.${destination}</p>`
318
+ : "<p>Tu sesión del aula venció. Entrá con tu usuario de la UTN para renovarla.</p>";
319
+ const error = o.error ? `<p class=error>${escapeHtml(o.error)}</p>` : "";
320
+ const hidden = o.pendingId ? `<input type="hidden" name="pedido" value="${escapeHtml(o.pendingId)}">` : "";
321
+ const selected = o.regional || defaultRegional().id;
322
+ const option = (r) => `<option value="${escapeHtml(r.id)}"${r.id === selected ? " selected" : ""}>${escapeHtml(r.name)}</option>`;
323
+ const group = (label, support) => {
324
+ const items = o.regionals.filter((r) => r.support === support);
325
+ return items.length ? `<optgroup label="${escapeHtml(label)}">${items.map(option).join("")}</optgroup>` : "";
326
+ };
327
+ const options = group("Regionales", "verified") +
328
+ group("Agregar mi regional (primera vez acá)", "candidate") +
329
+ group("Todavía sin soporte", "unsupported");
330
+ return page("Entrar con la UTN", intro +
331
+ warning +
332
+ error +
333
+ `<form method="post" action="${o.action}">${hidden}` +
334
+ `<label>Regional<select name="regional" required>${options}</select></label>` +
335
+ `<label>Usuario<input name="usuario" autocomplete="username" autocapitalize="none" required></label>` +
336
+ `<label>Contraseña<input name="contrasena" type="password" autocomplete="current-password" required></label>` +
337
+ consent +
338
+ `<button type="submit">Entrar</button></form>` +
339
+ "<p class=nota>La contraseña se usa una sola vez para entrar al aula y no se guarda. " +
340
+ 'El código es abierto: <a href="https://github.com/nialvape/utn-mcp">github.com/nialvape/utn-mcp</a>.</p>', o.pendingId ? "Conectar app · Aula Virtual" : "Renovar sesión · Aula Virtual");
341
+ }
package/build/server.js CHANGED
@@ -7,7 +7,7 @@ import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
7
7
  export function createServer(aula, files) {
8
8
  const server = new McpServer({
9
9
  name: "UTN-MCP",
10
- version: "0.6.9",
10
+ version: "0.8.0",
11
11
  });
12
12
  registerAulaVirtualTools(server, aula, files);
13
13
  return server;
@@ -9,6 +9,66 @@ export async function ask(question) {
9
9
  rl.close();
10
10
  }
11
11
  }
12
+ /**
13
+ * Elige una de `options` con las flechas (o su número) y Enter; devuelve el índice. Sin terminal
14
+ * interactiva (stdin redirigido) muestra la lista numerada y pide el número. Enter solo elige `initial`.
15
+ */
16
+ export async function askChoice(question, options, initial = 0) {
17
+ if (!stdin.isTTY) {
18
+ const list = options.map((o, i) => ` ${i + 1}. ${o}`).join("\n");
19
+ const answer = await ask(`${question}\n${list}\nElegí una [${initial + 1}]: `);
20
+ if (!answer)
21
+ return initial;
22
+ const index = Number(answer) - 1;
23
+ if (!options[index])
24
+ throw new Error(`"${answer}" no es una opción: elegí un número del 1 al ${options.length}.`);
25
+ return index;
26
+ }
27
+ return new Promise((resolve, reject) => {
28
+ let selected = initial;
29
+ // Reescribe la lista en el lugar: sube tantas líneas como opciones y borra cada una.
30
+ const render = (redraw) => {
31
+ if (redraw)
32
+ stderr.write(`\x1b[${options.length}A`);
33
+ for (const [i, o] of options.entries())
34
+ stderr.write(`\x1b[2K${i === selected ? "❯" : " "} ${o}\n`);
35
+ };
36
+ const onData = (chunk) => {
37
+ if (chunk === "\u0003") {
38
+ done();
39
+ reject(new Error("Cancelado"));
40
+ return;
41
+ }
42
+ if (chunk === "\r" || chunk === "\n") {
43
+ done();
44
+ stderr.write(`\x1b[${options.length + 1}A\x1b[J${question} ${options[selected]}\n`);
45
+ resolve(selected);
46
+ return;
47
+ }
48
+ if (chunk === "\x1b[A")
49
+ selected = (selected - 1 + options.length) % options.length;
50
+ else if (chunk === "\x1b[B")
51
+ selected = (selected + 1) % options.length;
52
+ else if (options[Number(chunk) - 1])
53
+ selected = Number(chunk) - 1;
54
+ else
55
+ return;
56
+ render(true);
57
+ };
58
+ const done = () => {
59
+ stdin.off("data", onData);
60
+ stdin.setRawMode(false);
61
+ stdin.pause();
62
+ stderr.write("\x1b[?25h"); // vuelve a mostrar el cursor
63
+ };
64
+ stderr.write(`\x1b[?25l${question} (flechas y Enter)\n`);
65
+ render(false);
66
+ stdin.setRawMode(true);
67
+ stdin.resume();
68
+ stdin.setEncoding("utf8");
69
+ stdin.on("data", onData);
70
+ });
71
+ }
12
72
  export function askHidden(question) {
13
73
  if (!stdin.isTTY)
14
74
  return ask(question);
@@ -0,0 +1,5 @@
1
+ import { existsSync } from "node:fs";
2
+ // Se evalúa antes que el resto de los módulos: así las variables del `.env` (como UTN_REGIONAL)
3
+ // están definidas desde que arranca el proceso.
4
+ if (existsSync(".env"))
5
+ process.loadEnvFile(".env");