utn-mcp 0.1.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/LICENSE +15 -0
- package/README.md +56 -0
- package/build/aula-virtual/client/client.js +16 -0
- package/build/aula-virtual/client/constants.js +2 -0
- package/build/aula-virtual/client/credentials.js +33 -0
- package/build/aula-virtual/client/local-session.js +49 -0
- package/build/aula-virtual/client/moodle-api.js +32 -0
- package/build/aula-virtual/client/session.js +2 -0
- package/build/aula-virtual/client/sso-login.js +98 -0
- package/build/aula-virtual/login.js +24 -0
- package/build/aula-virtual/tools.js +27 -0
- package/build/aula-virtual/types.js +2 -0
- package/build/http.js +24 -0
- package/build/index.js +44 -0
- package/build/server.js +11 -0
- package/build/shared/http.js +31 -0
- package/build/shared/input.js +47 -0
- package/build/shared/logger.js +6 -0
- package/build/shared/paths.js +13 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joaquín Peñalva
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# UTN-MCP — El puente entre tu agente y los sistemas de la Universidad.
|
|
2
|
+
Este es el Model Context Protocol server NO OFICIAL de la UTN. Con él podrás pedirle a tu agente de preferencia (ChatGPT, Claude, etc.) que busque informacion o archivos en el aula virtual.
|
|
3
|
+
|
|
4
|
+
## Features
|
|
5
|
+
Actualmente solamente tiene dos tools, aula_get_site_info() y aula_list_courses(). Ya se incorporarán más!
|
|
6
|
+
|
|
7
|
+
## Cómo instalar
|
|
8
|
+
Necesitás [Node.js](https://nodejs.org) 20 o superior. No hace falta clonar el repo ni instalar nada a mano: `npx` descarga el paquete solo.
|
|
9
|
+
|
|
10
|
+
### 1. Iniciar sesión
|
|
11
|
+
```bash
|
|
12
|
+
npx -y utn-mcp login
|
|
13
|
+
```
|
|
14
|
+
Te pide tu usuario institucional y tu contraseña de la UTN (los mismos de "Usuarios Institucionales" en el aula virtual). Se hace una sola vez: después el servidor renueva la sesión solo.
|
|
15
|
+
|
|
16
|
+
### 2. Agregarlo a tu cliente
|
|
17
|
+
|
|
18
|
+
**Claude Code**
|
|
19
|
+
```bash
|
|
20
|
+
claude mcp add utn -- npx -y utn-mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Claude Desktop**: en *Settings → Developer → Edit Config*, agregá dentro de `mcpServers`:
|
|
24
|
+
```json
|
|
25
|
+
"utn": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "utn-mcp"]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
En Windows, si no arranca, usá `"command": "cmd"` y `"args": ["/c", "npx", "-y", "utn-mcp"]`. Después reiniciá Claude Desktop por completo.
|
|
31
|
+
|
|
32
|
+
**Otros clientes (Cursor, VS Code, etc.)**: cualquier cliente MCP que lance servidores por STDIO sirve con el comando `npx -y utn-mcp`.
|
|
33
|
+
|
|
34
|
+
### Instalación global (opcional)
|
|
35
|
+
Si preferís tener el comando `utn-mcp` siempre disponible:
|
|
36
|
+
```bash
|
|
37
|
+
npm i -g utn-mcp
|
|
38
|
+
utn-mcp login
|
|
39
|
+
```
|
|
40
|
+
y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
|
|
41
|
+
|
|
42
|
+
### Modo HTTP (opcional)
|
|
43
|
+
```bash
|
|
44
|
+
npx -y utn-mcp --http --port 3000
|
|
45
|
+
```
|
|
46
|
+
Levanta el servidor en `http://127.0.0.1:3000/mcp`, solo accesible desde tu propia máquina. Tenés que dejar la terminal abierta mientras lo uses.
|
|
47
|
+
|
|
48
|
+
## Dónde se guardan tus datos
|
|
49
|
+
Todo queda en tu computadora:
|
|
50
|
+
- **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
|
|
51
|
+
- **Token del aula virtual y cookies de sesión**: en `%APPDATA%\utn-mcp\session.json` (Windows) o `~/.config/utn-mcp/session.json` (Linux y macOS).
|
|
52
|
+
|
|
53
|
+
En Linux sin gestor de credenciales (servidores, Docker, WSL) podés definir las variables de entorno `UTN_USER` y `UTN_PASS` en lugar de usar `login`.
|
|
54
|
+
|
|
55
|
+
## Aviso
|
|
56
|
+
Este proyecto no está afiliado ni avalado por la UTN. Usalo bajo tu responsabilidad.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { callWs } from "./moodle-api.js";
|
|
2
|
+
export function getSiteInfo(session) {
|
|
3
|
+
return callWs(session, "core_webservice_get_site_info");
|
|
4
|
+
}
|
|
5
|
+
export async function listCourses(session) {
|
|
6
|
+
const { userid } = await getSiteInfo(session);
|
|
7
|
+
const courses = await callWs(session, "core_enrol_get_users_courses", { userid });
|
|
8
|
+
return courses.map((c) => ({
|
|
9
|
+
id: c.id,
|
|
10
|
+
nombre: c.fullname,
|
|
11
|
+
nombreCorto: c.shortname,
|
|
12
|
+
visible: c.visible === 1,
|
|
13
|
+
progreso: c.progress ?? null,
|
|
14
|
+
ultimoAcceso: c.lastaccess ? new Date(c.lastaccess * 1000).toISOString() : null,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { logger } from "../../shared/logger.js";
|
|
2
|
+
const SERVICE = "utn-mcp";
|
|
3
|
+
const ACCOUNT = "aula-virtual";
|
|
4
|
+
// Import dinámico: si el binario nativo o el Secret Service (Linux) no están, seguimos con env vars.
|
|
5
|
+
async function entry() {
|
|
6
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
7
|
+
return new Entry(SERVICE, ACCOUNT);
|
|
8
|
+
}
|
|
9
|
+
/** Primero UTN_USER/UTN_PASS; si no están, el keychain del sistema. */
|
|
10
|
+
export async function loadCredentials() {
|
|
11
|
+
const { UTN_USER, UTN_PASS } = process.env;
|
|
12
|
+
if (UTN_USER && UTN_PASS)
|
|
13
|
+
return { username: UTN_USER, password: UTN_PASS };
|
|
14
|
+
try {
|
|
15
|
+
const raw = (await entry()).getPassword();
|
|
16
|
+
return raw ? JSON.parse(raw) : null;
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
logger.warn("Keychain no disponible:", e.message);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Guarda en el keychain. Devuelve false si no hay keychain (ej. Linux sin Secret Service). */
|
|
24
|
+
export async function saveCredentials(creds) {
|
|
25
|
+
try {
|
|
26
|
+
(await entry()).setPassword(JSON.stringify(creds));
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
logger.warn("No se pudo guardar en el keychain:", e.message);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { CookieJar } from "tough-cookie";
|
|
4
|
+
import { sessionFile } from "../../shared/paths.js";
|
|
5
|
+
import { logger } from "../../shared/logger.js";
|
|
6
|
+
import { loadCredentials } from "./credentials.js";
|
|
7
|
+
import { fetchToken } from "./sso-login.js";
|
|
8
|
+
/**
|
|
9
|
+
* Sesión del modo local: un solo usuario, guardada en disco.
|
|
10
|
+
* Credenciales en el keychain (o UTN_USER/UTN_PASS); token y cookies del SSO en session.json.
|
|
11
|
+
*/
|
|
12
|
+
export class LocalSession {
|
|
13
|
+
refreshing = null;
|
|
14
|
+
async getToken() {
|
|
15
|
+
return (await this.read()).token ?? this.refreshToken();
|
|
16
|
+
}
|
|
17
|
+
/** Una sola renovación en curso aunque varias tools fallen a la vez. */
|
|
18
|
+
refreshToken() {
|
|
19
|
+
this.refreshing ??= loadCredentials()
|
|
20
|
+
.then((creds) => this.login(creds))
|
|
21
|
+
.finally(() => {
|
|
22
|
+
this.refreshing = null;
|
|
23
|
+
});
|
|
24
|
+
return this.refreshing;
|
|
25
|
+
}
|
|
26
|
+
/** Obtiene un token nuevo y lo guarda junto con las cookies del SSO. */
|
|
27
|
+
async login(credentials) {
|
|
28
|
+
logger.info("Obteniendo token del aula virtual...");
|
|
29
|
+
const stored = await this.read();
|
|
30
|
+
const jar = stored.cookies ? await CookieJar.deserialize(stored.cookies) : new CookieJar();
|
|
31
|
+
const token = await fetchToken(jar, credentials);
|
|
32
|
+
await this.write({ token, obtainedAt: new Date().toISOString(), cookies: await jar.serialize() });
|
|
33
|
+
return token;
|
|
34
|
+
}
|
|
35
|
+
async read() {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(await readFile(sessionFile(), "utf8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { token: null, obtainedAt: null, cookies: null };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async write(data) {
|
|
44
|
+
const file = sessionFile();
|
|
45
|
+
await mkdir(dirname(file), { recursive: true });
|
|
46
|
+
await writeFile(file, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
47
|
+
await chmod(file, 0o600).catch(() => { }); // no-op en Windows
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { postForm } from "../../shared/http.js";
|
|
2
|
+
import { AULA_BASE_URL } from "./constants.js";
|
|
3
|
+
const WS_URL = `${AULA_BASE_URL}/webservice/rest/server.php`;
|
|
4
|
+
const TOKEN_ERRORS = new Set(["invalidtoken", "expiredtoken"]);
|
|
5
|
+
export class MoodleError extends Error {
|
|
6
|
+
errorcode;
|
|
7
|
+
constructor(message, errorcode) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.errorcode = errorcode;
|
|
10
|
+
this.name = "MoodleError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const isException = (data) => typeof data === "object" && data !== null && "exception" in data;
|
|
14
|
+
/** Llama a una función del web service de Moodle; renueva el token una vez si venció. */
|
|
15
|
+
export async function callWs(session, wsfunction, params = {}) {
|
|
16
|
+
let token = await session.getToken();
|
|
17
|
+
for (let attempt = 0;; attempt++) {
|
|
18
|
+
const data = await postForm(WS_URL, {
|
|
19
|
+
...params,
|
|
20
|
+
wstoken: token,
|
|
21
|
+
wsfunction,
|
|
22
|
+
moodlewsrestformat: "json",
|
|
23
|
+
});
|
|
24
|
+
if (!isException(data))
|
|
25
|
+
return data;
|
|
26
|
+
if (attempt === 0 && data.errorcode && TOKEN_ERRORS.has(data.errorcode)) {
|
|
27
|
+
token = await session.refreshToken();
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
throw new MoodleError(data.message ?? data.exception, data.errorcode);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { AULA_BASE_URL, SSO_HOST } from "./constants.js";
|
|
3
|
+
export class LoginRequiredError extends Error {
|
|
4
|
+
constructor(message = "Hace falta iniciar sesión: corré `utn-mcp login`.") {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "LoginRequiredError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class InvalidCredentialsError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("El SSO rechazó usuario o contraseña. Corré `utn-mcp login` para actualizarlos.");
|
|
12
|
+
this.name = "InvalidCredentialsError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const MAX_REDIRECTS = 20;
|
|
16
|
+
const TIMEOUT_MS = 30_000;
|
|
17
|
+
/**
|
|
18
|
+
* Obtiene un token del web service siguiendo el flujo de launch.php por HTTP:
|
|
19
|
+
* launch.php → login de Moodle → SSO (Keycloak) → Moodle → launch.php → moodlemobile://token=BASE64.
|
|
20
|
+
* Las cookies del SSO quedan en `jar`: mientras sigan vivas no hace falta la contraseña.
|
|
21
|
+
*/
|
|
22
|
+
export async function fetchToken(jar, credentials) {
|
|
23
|
+
const launchUrl = `${AULA_BASE_URL}/admin/tool/mobile/launch.php` +
|
|
24
|
+
`?service=moodle_mobile_app&passport=${randomInt(1e9)}&urlscheme=moodlemobile`;
|
|
25
|
+
// 1. Con sesión de Moodle viva, launch.php redirige directo al token.
|
|
26
|
+
let landing = await follow(jar, launchUrl);
|
|
27
|
+
if ("token" in landing)
|
|
28
|
+
return landing.token;
|
|
29
|
+
// 2. Login de Moodle: el botón "Usuarios Institucionales" lleva al SSO.
|
|
30
|
+
const oauthUrl = landing.html.match(/href="([^"]*\/auth\/oauth2\/login\.php[^"]*)"/)?.[1];
|
|
31
|
+
if (!oauthUrl)
|
|
32
|
+
throw unexpected(landing.url);
|
|
33
|
+
landing = await follow(jar, decodeHtml(oauthUrl));
|
|
34
|
+
if ("token" in landing)
|
|
35
|
+
return landing.token;
|
|
36
|
+
// 3. Si el SSO no nos recuerda, muestra su formulario: lo completamos.
|
|
37
|
+
if (new URL(landing.url).host === SSO_HOST) {
|
|
38
|
+
if (!credentials)
|
|
39
|
+
throw new LoginRequiredError();
|
|
40
|
+
landing = await submitSsoForm(jar, landing.html, credentials);
|
|
41
|
+
if ("token" in landing)
|
|
42
|
+
return landing.token;
|
|
43
|
+
if (new URL(landing.url).host === SSO_HOST)
|
|
44
|
+
throw new InvalidCredentialsError();
|
|
45
|
+
}
|
|
46
|
+
// 4. Ya con sesión de Moodle (el SSO nos deja en el dashboard), pedimos el token de nuevo.
|
|
47
|
+
landing = await follow(jar, launchUrl);
|
|
48
|
+
if ("token" in landing)
|
|
49
|
+
return landing.token;
|
|
50
|
+
throw unexpected(landing.url);
|
|
51
|
+
}
|
|
52
|
+
async function submitSsoForm(jar, html, { username, password }) {
|
|
53
|
+
// La página de Keycloak trae su config como JSON (kcContext); de ahí sale la URL del form.
|
|
54
|
+
const action = html.match(/"loginAction"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
|
|
55
|
+
if (!action)
|
|
56
|
+
throw new Error("No se encontró el formulario del SSO; puede que haya cambiado.");
|
|
57
|
+
const body = new URLSearchParams({ username, password, rememberMe: "on", credentialId: "" });
|
|
58
|
+
return follow(jar, JSON.parse(action), { method: "POST", body });
|
|
59
|
+
}
|
|
60
|
+
/** Hace la request y sigue redirecciones a mano, guardando cookies en cada salto. */
|
|
61
|
+
async function follow(jar, url, init) {
|
|
62
|
+
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
63
|
+
const cookie = await jar.getCookieString(url);
|
|
64
|
+
const res = await fetch(url, {
|
|
65
|
+
...init,
|
|
66
|
+
redirect: "manual",
|
|
67
|
+
headers: cookie ? { cookie } : {},
|
|
68
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
69
|
+
});
|
|
70
|
+
for (const setCookie of res.headers.getSetCookie()) {
|
|
71
|
+
await jar.setCookie(setCookie, url, { ignoreError: true });
|
|
72
|
+
}
|
|
73
|
+
const location = res.headers.get("location");
|
|
74
|
+
if (res.status >= 300 && res.status < 400 && location) {
|
|
75
|
+
await res.body?.cancel();
|
|
76
|
+
if (location.startsWith("moodlemobile://"))
|
|
77
|
+
return { token: decodeLaunchToken(location) };
|
|
78
|
+
url = new URL(location, url).toString();
|
|
79
|
+
init = undefined; // tras un redirect, siempre GET
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
return { url, html: await res.text() };
|
|
83
|
+
}
|
|
84
|
+
throw new Error("Demasiadas redirecciones en el login.");
|
|
85
|
+
}
|
|
86
|
+
/** moodlemobile://token=BASE64(siteid:::token[:::privatetoken]) → token */
|
|
87
|
+
export function decodeLaunchToken(url) {
|
|
88
|
+
const encoded = decodeURIComponent(url.split("token=")[1] ?? "");
|
|
89
|
+
const parts = Buffer.from(encoded, "base64").toString("utf8").split(":::");
|
|
90
|
+
if (parts.length < 2 || !parts[1])
|
|
91
|
+
throw new Error("Formato de token inesperado en launch.php");
|
|
92
|
+
return parts[1];
|
|
93
|
+
}
|
|
94
|
+
const decodeHtml = (s) => s.replaceAll("&", "&");
|
|
95
|
+
const unexpected = (url) => {
|
|
96
|
+
const { host, pathname } = new URL(url);
|
|
97
|
+
return new Error(`Flujo de login inesperado: terminó en ${host}${pathname}`);
|
|
98
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ask, askHidden } from "../shared/input.js";
|
|
2
|
+
import { logger } from "../shared/logger.js";
|
|
3
|
+
import { saveCredentials } from "./client/credentials.js";
|
|
4
|
+
import { LocalSession } from "./client/local-session.js";
|
|
5
|
+
/**
|
|
6
|
+
* `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
|
|
7
|
+
* y obtiene el primer token. Después el servidor renueva solo.
|
|
8
|
+
*/
|
|
9
|
+
export async function runLogin() {
|
|
10
|
+
const username = await ask("Usuario institucional de la UTN: ");
|
|
11
|
+
if (!username)
|
|
12
|
+
throw new Error("Falta el usuario.");
|
|
13
|
+
const password = await askHidden("Contraseña: ");
|
|
14
|
+
const credentials = { username, password };
|
|
15
|
+
await new LocalSession().login(credentials);
|
|
16
|
+
// Se guardan recién después de comprobar que el SSO las aceptó.
|
|
17
|
+
if (await saveCredentials(credentials)) {
|
|
18
|
+
logger.info("Credenciales guardadas en el keychain del sistema.");
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER y UTN_PASS.");
|
|
22
|
+
}
|
|
23
|
+
logger.info("Listo: ya podés usar el servidor MCP.");
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getSiteInfo, listCourses } from "./client/client.js";
|
|
2
|
+
const json = (data) => ({
|
|
3
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
4
|
+
});
|
|
5
|
+
const failure = (e) => ({
|
|
6
|
+
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
7
|
+
isError: true,
|
|
8
|
+
});
|
|
9
|
+
export function registerAulaVirtualTools(server, session) {
|
|
10
|
+
server.registerTool("aula_get_site_info", { description: "Datos del usuario logueado en el aula virtual de la UTN FRBA y del sitio Moodle." }, async () => {
|
|
11
|
+
try {
|
|
12
|
+
const { sitename, fullname, username, userid, release } = await getSiteInfo(session);
|
|
13
|
+
return json({ sitename, fullname, username, userid, release });
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
return failure(e);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
server.registerTool("aula_list_courses", { description: "Lista los cursos (materias) en los que está inscripto el usuario en el aula virtual." }, async () => {
|
|
20
|
+
try {
|
|
21
|
+
return json(await listCourses(session));
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
return failure(e);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
package/build/http.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
3
|
+
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
|
|
4
|
+
import { logger } from "./shared/logger.js";
|
|
5
|
+
const asMiddleware = (guard) => (req, res, next) => {
|
|
6
|
+
if (guard(req, res))
|
|
7
|
+
next();
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Entrada HTTP local: solo escucha en 127.0.0.1 y rechaza Host/Origin que no sean
|
|
11
|
+
* localhost, para que una página web no pueda usar tu sesión de la UTN (DNS rebinding).
|
|
12
|
+
* El modo hosteado va a reutilizar el mismo `factory`, con OAuth y una sesión por usuario.
|
|
13
|
+
*/
|
|
14
|
+
export function startHttpServer(factory, port) {
|
|
15
|
+
const handler = createMcpHandler(factory, { onerror: (e) => logger.error(e.message) });
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(asMiddleware(localhostHostValidation()));
|
|
18
|
+
app.use(asMiddleware(localhostOriginValidation()));
|
|
19
|
+
// Sin express.json(): el handler lee el body por su cuenta.
|
|
20
|
+
app.all("/mcp", toNodeHandler(handler));
|
|
21
|
+
app.listen(port, "127.0.0.1", () => {
|
|
22
|
+
logger.info(`Servidor MCP escuchando en http://127.0.0.1:${port}/mcp`);
|
|
23
|
+
});
|
|
24
|
+
}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
|
+
import { LocalSession } from "./aula-virtual/client/local-session.js";
|
|
5
|
+
import { runLogin } from "./aula-virtual/login.js";
|
|
6
|
+
import { startHttpServer } from "./http.js";
|
|
7
|
+
import { createServer } from "./server.js";
|
|
8
|
+
import { logger } from "./shared/logger.js";
|
|
9
|
+
const USAGE = `Uso:
|
|
10
|
+
utn-mcp servidor MCP por STDIO (lo lanza el cliente)
|
|
11
|
+
utn-mcp --http [--port N] servidor MCP por HTTP en 127.0.0.1 (por defecto 3000)
|
|
12
|
+
utn-mcp login guardar credenciales de la UTN y obtener el primer token`;
|
|
13
|
+
async function main() {
|
|
14
|
+
const { values, positionals } = parseArgs({
|
|
15
|
+
allowPositionals: true,
|
|
16
|
+
options: {
|
|
17
|
+
http: { type: "boolean", default: false },
|
|
18
|
+
port: { type: "string", default: process.env.PORT ?? "3000" },
|
|
19
|
+
help: { type: "boolean", short: "h", default: false },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (values.help) {
|
|
23
|
+
console.error(USAGE);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (positionals[0] === "login") {
|
|
27
|
+
await runLogin();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Modo local: un solo usuario, la misma sesión para todas las conexiones.
|
|
31
|
+
const aula = new LocalSession();
|
|
32
|
+
const factory = () => createServer(aula);
|
|
33
|
+
if (values.http) {
|
|
34
|
+
startHttpServer(factory, Number(values.port));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
serveStdio(factory, { onerror: (e) => logger.error(e.message) });
|
|
38
|
+
logger.info("Servidor MCP iniciado en STDIO");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
main().catch((e) => {
|
|
42
|
+
logger.error(e.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
package/build/server.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
|
|
3
|
+
/** Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request. */
|
|
4
|
+
export function createServer(aula) {
|
|
5
|
+
const server = new McpServer({
|
|
6
|
+
name: "UTN-MCP",
|
|
7
|
+
version: "0.1.0",
|
|
8
|
+
});
|
|
9
|
+
registerAulaVirtualTools(server, aula);
|
|
10
|
+
return server;
|
|
11
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(message, status) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.name = "HttpError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** POST application/x-www-form-urlencoded y parseo JSON, con timeout. */
|
|
10
|
+
export async function postForm(url, params, timeoutMs = 30_000) {
|
|
11
|
+
const body = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
|
|
12
|
+
let res;
|
|
13
|
+
try {
|
|
14
|
+
res = await fetch(url, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
body,
|
|
17
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
throw new HttpError(`No se pudo conectar con ${new URL(url).host}: ${e.message}`);
|
|
22
|
+
}
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
throw new HttpError(`HTTP ${res.status} en ${new URL(url).pathname}`, res.status);
|
|
25
|
+
try {
|
|
26
|
+
return (await res.json());
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new HttpError(`Respuesta no JSON en ${new URL(url).pathname}`, res.status);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { stdin, stderr } from "node:process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
export async function ask(question) {
|
|
4
|
+
const rl = createInterface({ input: stdin, output: stderr });
|
|
5
|
+
try {
|
|
6
|
+
return (await rl.question(question)).trim();
|
|
7
|
+
}
|
|
8
|
+
finally {
|
|
9
|
+
rl.close();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function askHidden(question) {
|
|
13
|
+
if (!stdin.isTTY)
|
|
14
|
+
return ask(question);
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
stderr.write(question);
|
|
17
|
+
stdin.setRawMode(true);
|
|
18
|
+
stdin.resume();
|
|
19
|
+
stdin.setEncoding("utf8");
|
|
20
|
+
let value = "";
|
|
21
|
+
const onData = (chunk) => {
|
|
22
|
+
for (const ch of chunk) {
|
|
23
|
+
if (ch === "\r" || ch === "\n") {
|
|
24
|
+
done();
|
|
25
|
+
resolve(value);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (ch === "\u0003") {
|
|
29
|
+
done();
|
|
30
|
+
reject(new Error("Cancelado"));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (ch === "\u007f" || ch === "\b")
|
|
34
|
+
value = value.slice(0, -1);
|
|
35
|
+
else
|
|
36
|
+
value += ch;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const done = () => {
|
|
40
|
+
stdin.off("data", onData);
|
|
41
|
+
stdin.setRawMode(false);
|
|
42
|
+
stdin.pause();
|
|
43
|
+
stderr.write("\n");
|
|
44
|
+
};
|
|
45
|
+
stdin.on("data", onData);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** Carpeta de configuración: UTN_MCP_HOME, %APPDATA% en Windows, o XDG/~/.config en Linux y macOS. */
|
|
4
|
+
export function configDir() {
|
|
5
|
+
if (process.env.UTN_MCP_HOME)
|
|
6
|
+
return process.env.UTN_MCP_HOME;
|
|
7
|
+
if (process.platform === "win32") {
|
|
8
|
+
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "utn-mcp");
|
|
9
|
+
}
|
|
10
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "utn-mcp");
|
|
11
|
+
}
|
|
12
|
+
/** Token de Moodle + cookies del SSO del modo local. */
|
|
13
|
+
export const sessionFile = () => join(configDir(), "session.json");
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "utn-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Este es el Model Context Protocol server NO OFICIAL de la UTN para buscar informacion o archivos en el aula virtual.",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"utn-mcp": "build/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"build"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"prebuild": "node -e \"require('fs').rmSync('build',{recursive:true,force:true})\"",
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "npm run build",
|
|
16
|
+
"start": "node build/index.js",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
19
|
+
"login": "tsx src/index.ts login"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/nialvape/utn-mcp.git"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"model-context-protocol",
|
|
28
|
+
"utn",
|
|
29
|
+
"aula-virtual"
|
|
30
|
+
],
|
|
31
|
+
"author": "Joaquín Peñalva (https://github.com/nialvape)",
|
|
32
|
+
"license": "ISC",
|
|
33
|
+
"type": "module",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/nialvape/utn-mcp/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/nialvape/utn-mcp#readme",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/node": "^2.1.1",
|
|
40
|
+
"@modelcontextprotocol/server": "^2.3.0",
|
|
41
|
+
"@napi-rs/keyring": "^2.1.0",
|
|
42
|
+
"express": "^5.2.1",
|
|
43
|
+
"tough-cookie": "^6.0.2",
|
|
44
|
+
"zod": "^4.6.5"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/express": "^5.0.6",
|
|
48
|
+
"@types/node": "^26.6.4",
|
|
49
|
+
"tsx": "^4.23.15",
|
|
50
|
+
"typescript": "^7.0.2"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20"
|
|
54
|
+
}
|
|
55
|
+
}
|