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.
- package/README.md +51 -8
- package/build/aula-virtual/client/client.js +9 -0
- package/build/aula-virtual/client/link.js +33 -0
- package/build/aula-virtual/client/local-session.js +21 -13
- package/build/aula-virtual/client/moodle-api.js +6 -5
- package/build/aula-virtual/client/regionals.js +103 -0
- package/build/aula-virtual/client/sso-login.js +123 -24
- package/build/aula-virtual/client/stored-session.js +36 -22
- package/build/aula-virtual/format/actions.js +1 -2
- package/build/aula-virtual/format/files.js +30 -11
- package/build/aula-virtual/login.js +25 -18
- package/build/aula-virtual/tools.js +11 -5
- package/build/http.js +90 -29
- package/build/index.js +28 -5
- package/build/oauth/access.js +52 -0
- package/build/oauth/admin.js +110 -0
- package/build/oauth/expiring.js +57 -0
- package/build/oauth/redirects.js +60 -0
- package/build/oauth/server.js +341 -0
- package/build/server.js +1 -1
- package/build/shared/input.js +60 -0
- package/build/shared/load-env.js +5 -0
- package/build/shared/pages.js +66 -0
- package/build/shared/paths.js +1 -1
- package/build/shared/store/sqlite-store.js +135 -8
- package/build/shared/uploads.js +9 -1
- package/package.json +58 -58
- package/build/aula-virtual/client/constants.js +0 -2
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
//
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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: `${
|
|
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
|
-
|
|
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
|
|
23
|
-
* cualquier otra URL estaríamos mandando
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
25
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
71
|
-
logger.info(`Servidor MCP escuchando en http
|
|
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
|
-
|
|
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 (
|
|
84
|
-
return void fail(
|
|
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:
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
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
|
|
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(
|
|
53
|
+
const inbox = new UploadInbox(baseUrl);
|
|
40
54
|
const factory = (ctx) => {
|
|
41
55
|
const userId = userIdOf(ctx.authInfo);
|
|
42
|
-
|
|
56
|
+
const aula = new StoredAulaSession(store, userId, loginUrl);
|
|
57
|
+
return createServer(aula, { local: false, inbox, owner: String(userId) });
|
|
43
58
|
};
|
|
44
|
-
|
|
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
|
+
}
|