utn-mcp 0.6.9 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,156 +1,180 @@
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
- ## Qué podés pedirle
5
- - *"¿Cuándo es el recuperatorio del primer parcial de Álgebra?"*
6
- - *"¿Qué avisó la profesora de Física esta semana?"*
7
- - *"Resumime la guía de la unidad 3 de Sistemas Operativos."*
8
- - *"Pasame el link para bajar el apunte de calorimetría."*
9
- - *"¿Qué nota me pusieron en el TP1 y qué me corrigieron?"*
10
- - *"Preguntá en el foro de consultas si el parcial incluye la unidad 4."*
11
- - *"Anotame en el grupo 6."*
12
- - *"Agendame el recuperatorio del 14 a las 18."*
13
-
14
- El agente recorre el aula como lo harías vos: entra al curso, mira los foros y abre los archivos que hagan falta.
15
-
16
- ## Tools
17
- | Tool | Qué hace |
18
- |---|---|
19
- | `aula_list_courses` | Lista tus cursos. Es el punto de partida. |
20
- | `aula_open` | Entra a un curso, un módulo, un foro o una discusión. Cada respuesta trae las direcciones de lo que hay adentro y de un nivel más arriba, así el agente navega sin perderse. |
21
- | `aula_read_file` | Lee un archivo del aula, por partes cuando es largo. |
22
- | `aula_get_site_info` | Datos de tu usuario en el aula virtual. |
23
- | `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
24
- | `aula_forum_edit_post` | Edita una publicación tuya. |
25
- | `aula_forum_delete_post` | Borra una publicación tuya. |
26
- | `aula_assign_submit` | Entrega una tarea: sube archivos de tu compu, escribe el texto en línea y confirma el envío. |
27
- | `aula_assign_upload_link` | Solo en modo HTTP: un link para subir los archivos de una entrega, que usa el agente con `curl` o vos desde el navegador. |
28
- | `aula_choice_answer` | Responde una elección o te anota en un grupo. |
29
- | `aula_send_message` | Manda un mensaje privado a un docente o compañero. |
30
- | `aula_calendar_add_event` | Agrega un recordatorio a tu calendario del aula. |
31
-
32
- Al abrir una tarea, `aula_open` muestra si la entregaste, cuándo vence y la devolución con la nota. Las tools que escriben le piden al agente que te muestre lo que va a hacer y espere tu confirmación.
33
-
34
- ### Archivos que puede leer
35
- - **PDF**: el texto, página por página. Los **escaneados** se mandan como imagen de cada página, y el agente los lee igual.
36
- - **Word (.docx), PowerPoint (.pptx) y Excel (.xlsx)**: el texto, con las tablas fila por fila.
37
- - **Imágenes** (PNG, JPG, GIF) y texto plano.
38
-
39
- Los formatos viejos de Office (.doc, .ppt, .pps), los comprimidos (.zip, .rar) y los videos no se leen. En ese caso el agente te pasa el link para que lo bajes.
40
-
41
- ### Links de descarga
42
- Cada archivo viene con un link del aula virtual que podés abrir en tu navegador. Funciona con tu sesión del aula (si no estás logueado te manda al login) y **nunca incluye tu token**: ese queda siempre en tu computadora.
43
-
44
- ## Cómo instalar
45
- 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.
46
-
47
- ### 1. Iniciar sesión
48
- ```bash
49
- npx -y utn-mcp login
50
- ```
51
- 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.
52
-
53
- ### 2. Agregarlo a tu cliente
54
-
55
- **Claude Code**
56
- ```bash
57
- claude mcp add utn -- npx -y utn-mcp
58
- ```
59
-
60
- **Claude Desktop**: en *Settings → Developer → Edit Config*, agregá dentro de `mcpServers`:
61
- ```json
62
- "utn": {
63
- "command": "npx",
64
- "args": ["-y", "utn-mcp"]
65
- }
66
- ```
67
- En Windows, si no arranca, usá `"command": "cmd"` y `"args": ["/c", "npx", "-y", "utn-mcp"]`. Después reiniciá Claude Desktop por completo.
68
-
69
- **Codex**
70
- ```bash
71
- codex mcp add utn -- npx -y utn-mcp
72
- ```
73
-
74
- **Gemini CLI**
75
- ```bash
76
- gemini mcp add utn npx -y utn-mcp
77
- ```
78
-
79
- **OpenClaw**
80
- ```bash
81
- openclaw mcp add utn --command npx --arg -y --arg utn-mcp
82
- ```
83
-
84
- **Hermes**: en `~/.hermes/config.yaml`:
85
- ```yaml
86
- mcp_servers:
87
- utn:
88
- command: "npx"
89
- args: ["-y", "utn-mcp"]
90
- ```
91
-
92
- **Cursor**: en `~/.cursor/mcp.json`:
93
- ```json
94
- {
95
- "mcpServers": {
96
- "utn": { "command": "npx", "args": ["-y", "utn-mcp"] }
97
- }
98
- }
99
- ```
100
-
101
- **VS Code**: en `.vscode/mcp.json` (o con *MCP: Add Server* desde la paleta de comandos):
102
- ```json
103
- {
104
- "servers": {
105
- "utn": { "type": "stdio", "command": "npx", "args": ["-y", "utn-mcp"] }
106
- }
107
- }
108
- ```
109
-
110
- **ChatGPT**: todavía no. ChatGPT sólo se conecta a servidores MCP remotos por HTTPS, y utn-mcp corre en tu computadora. Va a funcionar con la versión hosteada.
111
-
112
- **Otros clientes (Windsurf, opencode, etc.)**: cualquier cliente MCP que lance servidores por STDIO sirve con el comando `npx -y utn-mcp`.
113
-
114
- ### Instalación global (opcional)
115
- Si preferís tener el comando `utn-mcp` siempre disponible:
116
- ```bash
117
- npm i -g utn-mcp
118
- utn-mcp login
119
- ```
120
- y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
121
-
122
- ### Modo HTTP (opcional)
123
- Necesita Node 22.13 o más nuevo. Primero, una vez:
124
- ```bash
125
- npx -y utn-mcp login --http
126
- ```
127
- Te muestra un token: configurá tu cliente MCP para que mande el header `Authorization: Bearer <token>`. Después:
128
- ```bash
129
- npx -y utn-mcp --http --port 3000
130
- ```
131
- 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.
132
-
133
- En este modo el servidor no lee archivos de tu disco: para entregar, el agente pide un link con `aula_assign_upload_link` y sube ahí los archivos con `curl` si tiene terminal, o te pasa el link para que los elijas desde el navegador. Si el servidor se publica detrás de otra dirección, definí `UTN_MCP_PUBLIC_URL` para que los links la usen.
134
-
135
- ## Dónde se guardan tus datos
136
- Todo queda en tu computadora:
137
- - **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
138
- - **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).
139
- - **En modo HTTP**: la sesión va cifrada en `utn-mcp.db`, en la misma carpeta, con la clave de `store.key` (o la de `UTN_MCP_KEY`). Del token del cliente solo se guarda el hash.
140
-
141
- 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`.
142
-
143
- ## Desarrollo
144
- ```bash
145
- git clone https://github.com/nialvape/utn-mcp.git
146
- cd utn-mcp
147
- npm install
148
- npm run login # una vez
149
- npm run dev # servidor por STDIO desde src/, con tsx
150
- npm run build # compila a build/
151
- ```
152
-
153
- pdf.js no es una dependencia del paquete publicado. Está como `devDependency`, y `npm install` corre el script `prepare`, que copia a `vendor/pdfjs/` solo los archivos que usa el servidor (~4 MB). Así, quien instala `utn-mcp` no baja pdf.js entero ni su canvas nativo opcional (~37 MB). Si instalaste con `--ignore-scripts`, generalo a mano con `npm run prepare`.
154
-
155
- ## Aviso
156
- Este proyecto no está afiliado ni avalado por la UTN. Usalo bajo tu responsabilidad.
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
+ ## Qué podés pedirle
5
+ - *"¿Cuándo es el recuperatorio del primer parcial de Álgebra?"*
6
+ - *"¿Qué avisó la profesora de Física esta semana?"*
7
+ - *"Resumime la guía de la unidad 3 de Sistemas Operativos."*
8
+ - *"Pasame el link para bajar el apunte de calorimetría."*
9
+ - *"¿Qué nota me pusieron en el TP1 y qué me corrigieron?"*
10
+ - *"Preguntá en el foro de consultas si el parcial incluye la unidad 4."*
11
+ - *"Anotame en el grupo 6."*
12
+ - *"Agendame el recuperatorio del 14 a las 18."*
13
+
14
+ El agente recorre el aula como lo harías vos: entra al curso, mira los foros y abre los archivos que hagan falta.
15
+
16
+ ## Tools
17
+ | Tool | Qué hace |
18
+ |---|---|
19
+ | `aula_list_courses` | Lista tus cursos. Es el punto de partida. |
20
+ | `aula_open` | Entra a un curso, un módulo, un foro o una discusión. Cada respuesta trae las direcciones de lo que hay adentro y de un nivel más arriba, así el agente navega sin perderse. |
21
+ | `aula_read_file` | Lee un archivo del aula, por partes cuando es largo. |
22
+ | `aula_get_site_info` | Datos de tu usuario en el aula virtual. |
23
+ | `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
24
+ | `aula_forum_edit_post` | Edita una publicación tuya. |
25
+ | `aula_forum_delete_post` | Borra una publicación tuya. |
26
+ | `aula_assign_submit` | Entrega una tarea: sube archivos de tu compu, escribe el texto en línea y confirma el envío. |
27
+ | `aula_assign_upload_link` | Solo en modo HTTP: un link para subir los archivos de una entrega, que usa el agente con `curl` o vos desde el navegador. |
28
+ | `aula_choice_answer` | Responde una elección o te anota en un grupo. |
29
+ | `aula_send_message` | Manda un mensaje privado a un docente o compañero. |
30
+ | `aula_calendar_add_event` | Agrega un recordatorio a tu calendario del aula. |
31
+
32
+ Al abrir una tarea, `aula_open` muestra si la entregaste, cuándo vence y la devolución con la nota. Las tools que escriben le piden al agente que te muestre lo que va a hacer y espere tu confirmación.
33
+
34
+ ### Archivos que puede leer
35
+ - **PDF**: el texto, página por página. Los **escaneados** se mandan como imagen de cada página, y el agente los lee igual.
36
+ - **Word (.docx), PowerPoint (.pptx) y Excel (.xlsx)**: el texto, con las tablas fila por fila.
37
+ - **Imágenes** (PNG, JPG, GIF) y texto plano.
38
+
39
+ Los formatos viejos de Office (.doc, .ppt, .pps), los comprimidos (.zip, .rar) y los videos no se leen. En ese caso el agente te pasa el link para que lo bajes.
40
+
41
+ ### Links de descarga
42
+ Cada archivo viene con un link del aula virtual que podés abrir en tu navegador. Funciona con tu sesión del aula (si no estás logueado te manda al login) y **nunca incluye tu token**: ese queda siempre en tu computadora.
43
+
44
+ ## Cómo instalar
45
+ 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.
46
+
47
+ ### 1. Iniciar sesión
48
+ ```bash
49
+ npx -y utn-mcp login
50
+ ```
51
+ 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.
52
+
53
+ ### 2. Agregarlo a tu cliente
54
+
55
+ **Claude Code**
56
+ ```bash
57
+ claude mcp add utn -- npx -y utn-mcp
58
+ ```
59
+
60
+ **Claude Desktop**: en *Settings → Developer → Edit Config*, agregá dentro de `mcpServers`:
61
+ ```json
62
+ "utn": {
63
+ "command": "npx",
64
+ "args": ["-y", "utn-mcp"]
65
+ }
66
+ ```
67
+ En Windows, si no arranca, usá `"command": "cmd"` y `"args": ["/c", "npx", "-y", "utn-mcp"]`. Después reiniciá Claude Desktop por completo.
68
+
69
+ **Codex**
70
+ ```bash
71
+ codex mcp add utn -- npx -y utn-mcp
72
+ ```
73
+
74
+ **Gemini CLI**
75
+ ```bash
76
+ gemini mcp add utn npx -y utn-mcp
77
+ ```
78
+
79
+ **OpenClaw**
80
+ ```bash
81
+ openclaw mcp add utn --command npx --arg -y --arg utn-mcp
82
+ ```
83
+
84
+ **Hermes**: en `~/.hermes/config.yaml`:
85
+ ```yaml
86
+ mcp_servers:
87
+ utn:
88
+ command: "npx"
89
+ args: ["-y", "utn-mcp"]
90
+ ```
91
+
92
+ **Cursor**: en `~/.cursor/mcp.json`:
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "utn": { "command": "npx", "args": ["-y", "utn-mcp"] }
97
+ }
98
+ }
99
+ ```
100
+
101
+ **VS Code**: en `.vscode/mcp.json` (o con *MCP: Add Server* desde la paleta de comandos):
102
+ ```json
103
+ {
104
+ "servers": {
105
+ "utn": { "type": "stdio", "command": "npx", "args": ["-y", "utn-mcp"] }
106
+ }
107
+ }
108
+ ```
109
+
110
+ **ChatGPT**: todavía no. ChatGPT sólo se conecta a servidores MCP remotos por HTTPS, y utn-mcp corre en tu computadora. Va a funcionar con la versión hosteada.
111
+
112
+ **Otros clientes (Windsurf, opencode, etc.)**: cualquier cliente MCP que lance servidores por STDIO sirve con el comando `npx -y utn-mcp`.
113
+
114
+ ### Instalación global (opcional)
115
+ Si preferís tener el comando `utn-mcp` siempre disponible:
116
+ ```bash
117
+ npm i -g utn-mcp
118
+ utn-mcp login
119
+ ```
120
+ y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
121
+
122
+ ### Modo HTTP (opcional)
123
+ Necesita Node 22.13 o más nuevo. Primero, una vez:
124
+ ```bash
125
+ npx -y utn-mcp login --http
126
+ ```
127
+ Te muestra un token: configurá tu cliente MCP para que mande el header `Authorization: Bearer <token>`. Después:
128
+ ```bash
129
+ npx -y utn-mcp --http --port 3000
130
+ ```
131
+ 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.
132
+
133
+ En este modo el servidor no lee archivos de tu disco: para entregar, el agente pide un link con `aula_assign_upload_link` y sube ahí los archivos con `curl` si tiene terminal, o te pasa el link para que los elijas desde el navegador.
134
+
135
+ ### Hostearlo para otros
136
+ En modo HTTP el servidor también es servidor de autorización OAuth: un cliente MCP (Claude, ChatGPT) que se conecta a `https://tu-dominio/mcp` recibe un 401, descubre el OAuth y abre una página donde el alumno entra con su usuario de la UTN. Ese login vincula su aula: la contraseña se usa una sola vez para sacar el token de Moodle y **no se guarda**. Cuando la sesión del aula vence, el agente le pasa el link de `/login` para renovarla, sin reconectar el cliente.
137
+
138
+ Quién puede entrar lo decidís vos: con `UTN_MCP_ALLOWED_EMAILS` (una lista), o con `UTN_MCP_ACCESS_URL`, que apunta a un servicio tuyo. El servidor le hace `POST` con `{ "email", "usuario" }` y el header `Authorization: Bearer <UTN_MCP_ACCESS_SECRET>`, y espera `{ "permitido": true }` o `{ "permitido": false, "motivo": "...", "url": "..." }`. Se consulta al conectar y en cada renovación del token (cada hora), así que una baja se aplica sola.
139
+
140
+ La configuración va en variables de entorno o en un `.env` en la carpeta desde donde lo corrés (copiá [`.env.example`](.env.example)):
141
+
142
+ | Variable | Para qué | Por defecto |
143
+ |---|---|---|
144
+ | `HOST`, `PORT` | Dónde escucha. | `127.0.0.1`, `3000` |
145
+ | `UTN_MCP_PUBLIC_URL` | Dirección pública (`https://mcp.tu-dominio.com`). Es el issuer del OAuth, la base de los links de subida y el único host, además de localhost, que se acepta en `Host` y `Origin`. | `http://127.0.0.1:<PORT>` |
146
+ | `UTN_MCP_ACCESS_URL`, `UTN_MCP_ACCESS_SECRET` | Servicio que decide quién puede entrar. | — |
147
+ | `UTN_MCP_ALLOWED_EMAILS` | Emails o dominios (`@frba.utn.edu.ar`) habilitados, separados por coma, si no hay `UTN_MCP_ACCESS_URL`. | Entra cualquiera con usuario de la UTN |
148
+ | `UTN_MCP_ADMIN_SECRET` | Habilita `POST /admin/clientes` y `/admin/clientes/revocar`, para que un panel tuyo liste y desconecte las apps de cada usuario (por email). | — |
149
+ | `UTN_MCP_LOGIN_URL` | Página donde el usuario renueva su sesión del aula; el agente se la pasa. | `<UTN_MCP_PUBLIC_URL>/login` |
150
+ | `UTN_MCP_DB`, `UTN_MCP_KEY` | Base de sesiones y su clave de cifrado (32 bytes en base64). | En la carpeta de config |
151
+
152
+ Con Docker, detrás de un reverse proxy con HTTPS en la misma máquina (red host, para que el rate limit del login vea la IP real):
153
+ ```bash
154
+ docker build -t utn-mcp .
155
+ docker run -d --name utn-mcp --network host --restart unless-stopped -e HOST=127.0.0.1 -e PORT=8100 -e UTN_MCP_PUBLIC_URL=https://tu-dominio -e UTN_MCP_KEY=$(openssl rand -base64 32) -v utn-mcp-data:/data utn-mcp
156
+ ```
157
+ Guardá esa `UTN_MCP_KEY`: sin ella no se pueden descifrar las sesiones guardadas.
158
+
159
+ ## Dónde se guardan tus datos
160
+ Todo queda en tu computadora:
161
+ - **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
162
+ - **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).
163
+ - **En modo HTTP**: la contraseña no se guarda; la sesión va cifrada en `utn-mcp.db`, en la misma carpeta, con la clave de `store.key` (o la de `UTN_MCP_KEY`). Del token del cliente solo se guarda el hash.
164
+
165
+ 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`.
166
+
167
+ ## Desarrollo
168
+ ```bash
169
+ git clone https://github.com/nialvape/utn-mcp.git
170
+ cd utn-mcp
171
+ npm install
172
+ npm run login # una vez
173
+ npm run dev # servidor por STDIO desde src/, con tsx
174
+ npm run build # compila a build/
175
+ ```
176
+
177
+ pdf.js no es una dependencia del paquete publicado. Está como `devDependency`, y `npm install` corre el script `prepare`, que copia a `vendor/pdfjs/` solo los archivos que usa el servidor (~4 MB). Así, quien instala `utn-mcp` no baja pdf.js entero ni su canvas nativo opcional (~37 MB). Si instalaste con `--ignore-scripts`, generalo a mano con `npm run prepare`.
178
+
179
+ ## Aviso
180
+ Este proyecto no está afiliado ni avalado por la UTN. Usalo bajo tu responsabilidad.
@@ -2,6 +2,15 @@ import { callWs, MoodleError } from "./moodle-api.js";
2
2
  export function getSiteInfo(session) {
3
3
  return callWs(session, "core_webservice_get_site_info");
4
4
  }
5
+ /** El usuario dueño del token, con su email institucional (site_info no lo trae). */
6
+ export async function getOwnUser(session) {
7
+ const { userid, username } = await getSiteInfo(session);
8
+ const [user] = await callWs(session, "core_user_get_users_by_field", {
9
+ field: "id",
10
+ "values[0]": userid,
11
+ });
12
+ return { username, email: user?.email || null };
13
+ }
5
14
  export async function getUserCourses(session) {
6
15
  const { userid } = await getSiteInfo(session);
7
16
  return callWs(session, "core_enrol_get_users_courses", { userid });
@@ -0,0 +1,27 @@
1
+ import { CookieJar } from "tough-cookie";
2
+ import { getOwnUser } from "./client.js";
3
+ import { fetchToken } from "./sso-login.js";
4
+ import { AULA_PLATFORM } from "./stored-session.js";
5
+ /**
6
+ * Entra al aula con usuario y contraseña, crea el usuario del store si no existe y le guarda la
7
+ * sesión (token y cookies del SSO). La contraseña se usa solo acá: no se guarda en ningún lado.
8
+ * Lo usan `utn-mcp login --http` y el login por OAuth de la versión hosteada.
9
+ */
10
+ export async function linkAulaAccount(store, credentials) {
11
+ const jar = new CookieJar();
12
+ const token = await fetchToken(jar, credentials);
13
+ // El token recién emitido: si falla, no hay con qué renovarlo.
14
+ const fresh = {
15
+ getToken: async () => token,
16
+ refreshToken: async () => {
17
+ throw new Error("El aula rechazó el token recién emitido.");
18
+ },
19
+ };
20
+ const own = await getOwnUser(fresh);
21
+ // El de Moodle, en minúscula, igual que en `users`: es con lo que se compara al renovar.
22
+ const username = own.username.toLowerCase();
23
+ const userId = await store.findOrCreateUser(username, own.email ?? undefined);
24
+ const data = { username, token, cookies: await jar.serialize() };
25
+ await store.saveSession(userId, AULA_PLATFORM, JSON.stringify(data));
26
+ return { userId, username, email: own.email };
27
+ }
@@ -11,10 +11,13 @@ export const AULA_PLATFORM = "aula";
11
11
  export class StoredAulaSession {
12
12
  store;
13
13
  userId;
14
+ loginUrl;
14
15
  refreshing = null;
15
- constructor(store, userId) {
16
+ /** `loginUrl` es la página donde el usuario vuelve a iniciar sesión, si el servidor tiene una. */
17
+ constructor(store, userId, loginUrl) {
16
18
  this.store = store;
17
19
  this.userId = userId;
20
+ this.loginUrl = loginUrl;
18
21
  }
19
22
  async getToken() {
20
23
  return (await this.read()).token;
@@ -38,7 +41,7 @@ export class StoredAulaSession {
38
41
  }
39
42
  catch (e) {
40
43
  if (e instanceof LoginRequiredError) {
41
- throw new LoginRequiredError("La sesión del aula venció: volvé a iniciar sesión (`utn-mcp login --http`).");
44
+ throw this.loginRequired("La sesión del aula venció.");
42
45
  }
43
46
  throw e;
44
47
  }
@@ -49,7 +52,14 @@ export class StoredAulaSession {
49
52
  async read() {
50
53
  const raw = await this.store.loadSession(this.userId, AULA_PLATFORM);
51
54
  if (!raw)
52
- throw new LoginRequiredError("No hay sesión del aula para este usuario: corré `utn-mcp login --http`.");
55
+ throw this.loginRequired("No hay sesión del aula para este usuario.");
53
56
  return JSON.parse(raw);
54
57
  }
58
+ /** Hosteado, el usuario vuelve a entrar por la página del servidor; sin ella, por el CLI. */
59
+ loginRequired(reason) {
60
+ const next = this.loginUrl
61
+ ? `Pedile al usuario que vuelva a iniciar sesión en ${this.loginUrl} y después reintentá.`
62
+ : "Volvé a iniciar sesión con `utn-mcp login --http` en la máquina del servidor.";
63
+ return new LoginRequiredError(`${reason} ${next}`);
64
+ }
55
65
  }
@@ -1,11 +1,9 @@
1
- import { CookieJar } from "tough-cookie";
2
1
  import { ask, askHidden } from "../shared/input.js";
3
2
  import { logger } from "../shared/logger.js";
4
3
  import { SqliteStore } from "../shared/store/sqlite-store.js";
5
4
  import { saveCredentials } from "./client/credentials.js";
5
+ import { linkAulaAccount } from "./client/link.js";
6
6
  import { LocalSession } from "./client/local-session.js";
7
- import { fetchToken } from "./client/sso-login.js";
8
- import { AULA_PLATFORM } from "./client/stored-session.js";
9
7
  /**
10
8
  * `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
11
9
  * y obtiene el primer token. Después el servidor renueva solo.
@@ -39,12 +37,7 @@ export async function runLogin(http) {
39
37
  }
40
38
  /** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
41
39
  async function loginToStore(credentials) {
42
- const jar = new CookieJar();
43
- const token = await fetchToken(jar, credentials);
44
40
  const store = await SqliteStore.open();
45
- const userId = await store.findOrCreateUser(credentials.username);
46
- // En minúscula, igual que en `users`: es con lo que se compara al renovar.
47
- const data = { username: credentials.username.toLowerCase(), token, cookies: await jar.serialize() };
48
- await store.saveSession(userId, AULA_PLATFORM, JSON.stringify(data));
41
+ const { userId } = await linkAulaAccount(store, credentials);
49
42
  return store.issueToken(userId, { kind: "access", clientId: "local" });
50
43
  }
package/build/http.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import express from "express";
2
2
  import { Readable } from "node:stream";
3
3
  import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, OAuthErrorCode, verifyBearerToken, } from "@modelcontextprotocol/server";
4
- import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
4
+ import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node";
5
+ import { mountAdmin } from "./oauth/admin.js";
6
+ import { 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,31 @@ function storeVerifier(store) {
37
40
  },
38
41
  };
39
42
  }
43
+ const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
40
44
  /** El factory recibe en `ctx.authInfo` el token ya validado, con el usuario; de ahí arma su sesión. */
41
- export function startHttpServer(factory, port, store, inbox) {
45
+ export function startHttpServer(factory, options, store, inbox) {
46
+ const { host, port, publicUrl, access, 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
+ // Contra DNS rebinding: solo se aceptan los hosts de esta máquina y el de la URL pública.
53
+ const allowed = publicUrl ? [...LOOPBACK, new URL(publicUrl).hostname] : LOOPBACK;
54
+ if (!publicUrl && !LOOPBACK.includes(host)) {
55
+ logger.warn(`Escuchando en ${host} sin UTN_MCP_PUBLIC_URL: se rechazan los requests que no lleguen como localhost.`);
56
+ }
57
+ app.use(asMiddleware(hostHeaderValidation(allowed)));
58
+ // Origin solo donde un navegador ajeno no tiene nada que hacer; los endpoints de OAuth tienen CORS abierto.
59
+ const sameOrigin = asMiddleware(originValidation(allowed));
60
+ const resourceMetadataUrl = mountOAuth(app, {
61
+ issuer: publicUrl ?? `http://127.0.0.1:${port}`,
62
+ store,
63
+ access,
64
+ sameOrigin,
65
+ });
66
+ if (adminSecret)
67
+ mountAdmin(app, store, adminSecret);
44
68
  const requireToken = async (req, res, next) => {
45
69
  try {
46
70
  // toNodeHandler le pasa `req.auth` al handler como `authInfo`.
@@ -48,27 +72,27 @@ export function startHttpServer(factory, port, store, inbox) {
48
72
  next();
49
73
  }
50
74
  catch (e) {
51
- const challenge = bearerAuthChallengeResponse(e);
75
+ // El 401 dice dónde está la metadata: de ahí el cliente arranca el OAuth.
76
+ const challenge = bearerAuthChallengeResponse(e, { resourceMetadataUrl });
52
77
  res.status(challenge.status);
53
78
  challenge.headers.forEach((value, name) => res.setHeader(name, value));
54
79
  res.send(await challenge.text());
55
80
  }
56
81
  };
57
- const app = express();
58
- app.use(asMiddleware(localhostHostValidation()));
59
- app.use(asMiddleware(localhostOriginValidation()));
60
82
  // Sin express.json(): el handler lee el body por su cuenta.
61
- app.all("/mcp", requireToken, toNodeHandler(handler));
83
+ app.all("/mcp", sameOrigin, requireToken, toNodeHandler(handler));
62
84
  // 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);
85
+ app.get("/subir/:id", sameOrigin, (req, res) => {
86
+ const upload = inbox.get(String(req.params.id));
65
87
  if (!upload)
66
88
  return void res.status(404).type("html").send(page("Link vencido", EXPIRED));
67
89
  res.type("html").send(page(upload.title, uploadForm(upload.limits)));
68
90
  });
69
- app.post("/subir/:id", (req, res) => receiveUpload(inbox, req, res));
70
- app.listen(port, "127.0.0.1", () => {
71
- logger.info(`Servidor MCP escuchando en http://127.0.0.1:${port}/mcp`);
91
+ app.post("/subir/:id", sameOrigin, (req, res) => receiveUpload(inbox, req, res));
92
+ app.listen(port, host, () => {
93
+ logger.info(`Servidor MCP escuchando en http://${host}:${port}/mcp`);
94
+ if (publicUrl)
95
+ logger.info(`Publicado en ${publicUrl}/mcp`);
72
96
  });
73
97
  }
74
98
  /** Recibe el multipart del formulario o de `curl -F archivos=@...`. Al navegador le contesta HTML; al resto, JSON. */
@@ -124,14 +148,3 @@ function uploadForm(limits) {
124
148
  `<button type="submit">Subir</button></form>` +
125
149
  "<p class=nota>Subirlos no entrega la tarea: después el agente te confirma y la entrega.</p>");
126
150
  }
127
- /** Página mínima: se abre desde el celu, así que nada más que lo necesario. */
128
- function page(title, body) {
129
- return (`<!doctype html><html lang="es"><head><meta charset="utf-8">` +
130
- `<meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>` +
131
- "<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:2rem auto;padding:0 1rem;" +
132
- "line-height:1.5;color:#1a1a1a;background:#fff}@media(prefers-color-scheme:dark){body{color:#eee;" +
133
- "background:#151515}}form{display:flex;flex-direction:column;gap:1rem;margin:1.5rem 0}" +
134
- "button{padding:.7rem;font-size:1rem}.nota{opacity:.7;font-size:.9rem}</style></head>" +
135
- `<body><h1>${escapeHtml(title)}</h1>${body}</body></html>`);
136
- }
137
- const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
package/build/index.js CHANGED
@@ -1,24 +1,36 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
2
3
  import { parseArgs } from "node:util";
3
4
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
5
  import { LocalSession } from "./aula-virtual/client/local-session.js";
5
6
  import { StoredAulaSession } from "./aula-virtual/client/stored-session.js";
6
7
  import { runLogin } from "./aula-virtual/login.js";
7
8
  import { startHttpServer, userIdOf } from "./http.js";
9
+ import { accessPolicyFromEnv } from "./oauth/access.js";
8
10
  import { createServer } from "./server.js";
9
11
  import { logger } from "./shared/logger.js";
10
12
  import { SqliteStore } from "./shared/store/sqlite-store.js";
11
13
  import { UploadInbox } from "./shared/uploads.js";
12
- const USAGE = `Uso:
13
- utn-mcp servidor MCP por STDIO (lo lanza el cliente)
14
- utn-mcp --http [--port N] servidor MCP por HTTP en 127.0.0.1 (por defecto 3000)
15
- 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`;
14
+ const USAGE = `Uso:
15
+ utn-mcp servidor MCP por STDIO (lo lanza el cliente)
16
+ utn-mcp --http [--host H] [--port N] servidor MCP por HTTP (por defecto 127.0.0.1:3000)
17
+ utn-mcp login guardar credenciales de la UTN y obtener el primer token
18
+ utn-mcp login --http lo mismo, para el modo HTTP: guarda la sesión y emite el token del cliente
19
+
20
+ Si hay un .env en la carpeta actual, se lee al arrancar (ver .env.example).`;
21
+ /** Lee el `.env` de la carpeta actual, si hay. Lo que ya está definido en el entorno no se pisa. */
22
+ function loadDotenv() {
23
+ if (existsSync(".env"))
24
+ process.loadEnvFile(".env");
25
+ }
17
26
  async function main() {
27
+ loadDotenv();
18
28
  const { values, positionals } = parseArgs({
19
29
  allowPositionals: true,
20
30
  options: {
21
31
  http: { type: "boolean", default: false },
32
+ // Por defecto solo desde esta máquina; hosteado, `0.0.0.0` detrás del reverse proxy.
33
+ host: { type: "string", default: process.env.HOST ?? "127.0.0.1" },
22
34
  port: { type: "string", default: process.env.PORT ?? "3000" },
23
35
  help: { type: "boolean", short: "h", default: false },
24
36
  },
@@ -35,13 +47,25 @@ async function main() {
35
47
  // Cada request trae el token de un usuario del store y se arma con su sesión. Los archivos
36
48
  // llegan por links de subida: el servidor puede no estar en la compu de quien entrega.
37
49
  const port = Number(values.port);
50
+ const publicUrl = process.env.UTN_MCP_PUBLIC_URL?.replace(/\/+$/, "") || undefined;
51
+ const baseUrl = publicUrl ?? `http://127.0.0.1:${port}`;
52
+ // La página de `/login` del propio servidor, salvo que el que hostea prefiera mandar a otra.
53
+ const loginUrl = process.env.UTN_MCP_LOGIN_URL || `${baseUrl}/login`;
38
54
  const store = await SqliteStore.open();
39
- const inbox = new UploadInbox(process.env.UTN_MCP_PUBLIC_URL ?? `http://127.0.0.1:${port}`);
55
+ const inbox = new UploadInbox(baseUrl);
40
56
  const factory = (ctx) => {
41
57
  const userId = userIdOf(ctx.authInfo);
42
- return createServer(new StoredAulaSession(store, userId), { local: false, inbox, owner: String(userId) });
58
+ const aula = new StoredAulaSession(store, userId, loginUrl);
59
+ return createServer(aula, { local: false, inbox, owner: String(userId) });
60
+ };
61
+ const options = {
62
+ host: values.host,
63
+ port,
64
+ publicUrl,
65
+ access: accessPolicyFromEnv(),
66
+ adminSecret: process.env.UTN_MCP_ADMIN_SECRET || undefined,
43
67
  };
44
- startHttpServer(factory, port, store, inbox);
68
+ startHttpServer(factory, options, store, inbox);
45
69
  }
46
70
  else {
47
71
  // 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 }`; 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 ({ 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 }),
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
+ }
@@ -0,0 +1,48 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import express from "express";
3
+ import { AULA_PLATFORM } from "../aula-virtual/client/stored-session.js";
4
+ /**
5
+ * API para que otro servicio del que hostea (un panel de cuenta, por ejemplo) muestre y desconecte las
6
+ * apps de un usuario. Solo existe con UTN_MCP_ADMIN_SECRET, que va como `Authorization: Bearer`.
7
+ * Los usuarios se buscan por email institucional.
8
+ */
9
+ export function mountAdmin(app, store, secret) {
10
+ const json = express.json({ limit: "4kb" });
11
+ const requireSecret = (req, res, next) => {
12
+ const got = Buffer.from(req.headers.authorization ?? "");
13
+ const want = Buffer.from(`Bearer ${secret}`);
14
+ if (got.length === want.length && timingSafeEqual(got, want))
15
+ return next();
16
+ res.status(401).json({ error: "No autorizado." });
17
+ };
18
+ /** `{ email }` → el usuario, cuándo se renovó su sesión del aula y sus apps conectadas. */
19
+ app.post("/admin/clientes", requireSecret, json, async (req, res) => {
20
+ const email = typeof req.body?.email === "string" ? req.body.email : "";
21
+ const user = email ? await store.findUserByEmail(email) : null;
22
+ if (!user)
23
+ return void res.json({ clientes: [] });
24
+ const [clients, aulaAt] = await Promise.all([
25
+ store.listConnectedClients(user.id),
26
+ store.sessionUpdatedAt(user.id, AULA_PLATFORM),
27
+ ]);
28
+ res.json({
29
+ usuario: user.utnUsername,
30
+ sesionAula: aulaAt ? new Date(aulaAt * 1000).toISOString() : undefined,
31
+ clientes: clients.map((c) => ({
32
+ id: c.clientId,
33
+ nombre: c.clientName ?? (c.clientId === "local" ? "Token de utn-mcp login" : "App sin nombre"),
34
+ ultimoUso: new Date(c.lastTokenAt * 1000).toISOString(),
35
+ })),
36
+ });
37
+ });
38
+ /** `{ email, cliente }` → borra los tokens de esa app: tiene que volver a conectarse. */
39
+ app.post("/admin/clientes/revocar", requireSecret, json, async (req, res) => {
40
+ const email = typeof req.body?.email === "string" ? req.body.email : "";
41
+ const clientId = typeof req.body?.cliente === "string" ? req.body.cliente : "";
42
+ const user = email ? await store.findUserByEmail(email) : null;
43
+ if (!user || !clientId)
44
+ return void res.status(404).json({ error: "No existe ese usuario o esa app." });
45
+ await store.revokeClient(user.id, clientId);
46
+ res.json({ revocado: true });
47
+ });
48
+ }
@@ -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,272 @@
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 { InvalidCredentialsError } from "../aula-virtual/client/sso-login.js";
6
+ import { logger } from "../shared/logger.js";
7
+ import { escapeHtml, page } from "../shared/pages.js";
8
+ import { Expiring, RateLimiter } from "./expiring.js";
9
+ /** Duración del access token, en segundos: una baja de acceso se aplica a más tardar en este tiempo. */
10
+ const ACCESS_TTL = 60 * 60;
11
+ /** Duración del refresh token, en segundos. Rota en cada uso. */
12
+ const REFRESH_TTL = 30 * 24 * 60 * 60;
13
+ /** Cuánto vive un code sin canjear, en milisegundos. */
14
+ const CODE_TTL_MS = 60_000;
15
+ /** Cuánto tiene el usuario para completar el login, en milisegundos. */
16
+ const PENDING_TTL_MS = 10 * 60_000;
17
+ /** Intentos de login por IP cada 15 minutos: que el servidor no sirva para probar contraseñas contra el SSO. */
18
+ const LOGIN_ATTEMPTS = 10;
19
+ const LOGIN_WINDOW_MS = 15 * 60_000;
20
+ /** Esquemas de redirect_uri que nunca aceptamos. */
21
+ const FORBIDDEN_SCHEMES = new Set(["javascript:", "data:", "file:", "vbscript:"]);
22
+ /**
23
+ * Servidor de autorización OAuth 2.1 para los clientes MCP: registro dinámico, PKCE obligatorio y
24
+ * clientes públicos. El usuario se identifica con la UTN, y ese mismo login vincula su aula: la
25
+ * contraseña se usa una vez y no se guarda. Devuelve la URL de la metadata del recurso, que va en el 401.
26
+ */
27
+ export function mountOAuth(app, { issuer, store, access, sameOrigin }) {
28
+ const resource = `${issuer}/mcp`;
29
+ const metadata = {
30
+ issuer,
31
+ authorization_endpoint: `${issuer}/authorize`,
32
+ token_endpoint: `${issuer}/token`,
33
+ registration_endpoint: `${issuer}/register`,
34
+ revocation_endpoint: `${issuer}/revoke`,
35
+ response_types_supported: ["code"],
36
+ grant_types_supported: ["authorization_code", "refresh_token"],
37
+ code_challenge_methods_supported: ["S256"],
38
+ token_endpoint_auth_methods_supported: ["none"],
39
+ revocation_endpoint_auth_methods_supported: ["none"],
40
+ };
41
+ // Valida el issuer al arrancar (HTTPS salvo en localhost) en lugar de en el primer request.
42
+ const resourceMetadata = buildOAuthProtectedResourceMetadata({
43
+ oauthMetadata: metadata,
44
+ resourceServerUrl: new URL(resource),
45
+ resourceName: "UTN-MCP",
46
+ });
47
+ const pending = new Expiring(PENDING_TTL_MS);
48
+ const codes = new Expiring(CODE_TTL_MS);
49
+ const limiter = new RateLimiter(LOGIN_ATTEMPTS, LOGIN_WINDOW_MS);
50
+ const form = express.urlencoded({ extended: false, limit: "16kb" });
51
+ app.get("/.well-known/oauth-authorization-server", cors, (_req, res) => void res.json(metadata));
52
+ app.get(["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"], cors, (_req, res) => void res.json(resourceMetadata));
53
+ app.options(["/register", "/token", "/revoke"], cors, (_req, res) => void res.sendStatus(204));
54
+ app.post("/register", cors, express.json({ limit: "16kb" }), async (req, res) => {
55
+ const body = (req.body ?? {});
56
+ const uris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
57
+ if (!uris.length || !uris.every(isAllowedRedirect)) {
58
+ return void oauthError(res, 400, "invalid_redirect_uri", "Mandá al menos una redirect_uri válida.");
59
+ }
60
+ const client = {
61
+ clientId: randomUUID(),
62
+ clientName: typeof body.client_name === "string" ? body.client_name.slice(0, 100) : null,
63
+ redirectUris: uris,
64
+ };
65
+ await store.saveClient(client);
66
+ res.status(201).json({
67
+ client_id: client.clientId,
68
+ client_id_issued_at: Math.floor(Date.now() / 1000),
69
+ client_name: client.clientName ?? undefined,
70
+ redirect_uris: client.redirectUris,
71
+ grant_types: ["authorization_code", "refresh_token"],
72
+ response_types: ["code"],
73
+ token_endpoint_auth_method: "none",
74
+ });
75
+ });
76
+ app.get("/authorize", async (req, res) => {
77
+ const q = req.query;
78
+ const client = q.client_id ? await store.findClient(q.client_id) : null;
79
+ // Sin cliente o redirect_uri válidos no se redirige a ningún lado: el error lo ve el usuario.
80
+ if (!client)
81
+ return void errorPage(res, 400, "Cliente desconocido", "Este cliente no está registrado. Volvé a conectarlo desde tu app.");
82
+ if (!q.redirect_uri || !client.redirectUris.includes(q.redirect_uri)) {
83
+ return void errorPage(res, 400, "Redirección inválida", "La redirect_uri no es la que registró el cliente.");
84
+ }
85
+ const fail = (error, description) => redirectWith(res, q.redirect_uri, { error, error_description: description, state: q.state });
86
+ if (q.response_type !== "code")
87
+ return void fail("unsupported_response_type", "Solo se acepta response_type=code.");
88
+ if (!q.code_challenge || q.code_challenge_method !== "S256") {
89
+ return void fail("invalid_request", "PKCE es obligatorio, con code_challenge_method=S256.");
90
+ }
91
+ if (q.resource && !checkResourceAllowed({ requestedResource: q.resource, configuredResource: resource })) {
92
+ return void fail("invalid_target", `El único recurso de este servidor es ${resource}.`);
93
+ }
94
+ const id = pending.add({
95
+ clientId: client.clientId,
96
+ clientName: client.clientName,
97
+ redirectUri: q.redirect_uri,
98
+ codeChallenge: q.code_challenge,
99
+ state: q.state,
100
+ resource: q.resource,
101
+ });
102
+ res.type("html").send(loginPage({ action: "/authorize", pendingId: id, clientName: client.clientName }));
103
+ });
104
+ app.post("/authorize", sameOrigin, form, async (req, res) => {
105
+ const id = String(req.body?.pedido ?? "");
106
+ const request = pending.get(id);
107
+ if (!request)
108
+ return void errorPage(res, 400, "Pedido vencido", "Pasó demasiado tiempo. Volvé a conectar desde tu app.");
109
+ const retry = (error) => res.type("html").send(loginPage({ action: "/authorize", pendingId: id, clientName: request.clientName, error }));
110
+ const account = await login(req, retry);
111
+ if (!account)
112
+ return;
113
+ const decision = await access({ username: account.username, email: account.email });
114
+ if (!decision.allowed)
115
+ return void deniedPage(res, decision.reason, decision.url);
116
+ pending.take(id);
117
+ const code = codes.add({
118
+ userId: account.userId,
119
+ clientId: request.clientId,
120
+ redirectUri: request.redirectUri,
121
+ codeChallenge: request.codeChallenge,
122
+ });
123
+ logger.info(`OAuth: ${account.username} autorizó a ${request.clientName ?? request.clientId}.`);
124
+ redirectWith(res, request.redirectUri, { code, state: request.state, iss: issuer });
125
+ });
126
+ app.post("/token", cors, form, express.json({ limit: "16kb" }), async (req, res) => {
127
+ const body = (req.body ?? {});
128
+ try {
129
+ if (body.grant_type === "authorization_code") {
130
+ const code = body.code ? codes.take(body.code) : undefined;
131
+ if (!code)
132
+ return void oauthError(res, 400, "invalid_grant", "El code no existe, venció o ya se usó.");
133
+ if (body.client_id && body.client_id !== code.clientId) {
134
+ return void oauthError(res, 400, "invalid_grant", "El code es de otro cliente.");
135
+ }
136
+ if (body.redirect_uri && body.redirect_uri !== code.redirectUri) {
137
+ return void oauthError(res, 400, "invalid_grant", "La redirect_uri no coincide con la del pedido.");
138
+ }
139
+ if (!body.code_verifier || pkceChallenge(body.code_verifier) !== code.codeChallenge) {
140
+ return void oauthError(res, 400, "invalid_grant", "El code_verifier no corresponde al code_challenge.");
141
+ }
142
+ return void res.json(await issueTokens(store, code.userId, code.clientId));
143
+ }
144
+ if (body.grant_type === "refresh_token") {
145
+ const found = body.refresh_token ? await store.findToken(body.refresh_token, "refresh") : null;
146
+ if (!found)
147
+ return void oauthError(res, 400, "invalid_grant", "El refresh token no existe, venció o fue revocado.");
148
+ const clientId = found.clientId ?? body.client_id ?? "desconocido";
149
+ if (body.client_id && found.clientId && body.client_id !== found.clientId) {
150
+ return void oauthError(res, 400, "invalid_grant", "El refresh token es de otro cliente.");
151
+ }
152
+ await store.revokeToken(body.refresh_token);
153
+ // El acceso se vuelve a mirar en cada refresh: si la cuenta se dio de baja, acá se corta.
154
+ const user = await store.findUser(found.userId);
155
+ if (!user)
156
+ return void oauthError(res, 400, "invalid_grant", "El usuario ya no existe.");
157
+ const decision = await access({ username: user.utnUsername, email: user.email });
158
+ if (!decision.allowed)
159
+ return void oauthError(res, 400, "invalid_grant", decision.reason);
160
+ return void res.json(await issueTokens(store, user.id, clientId));
161
+ }
162
+ oauthError(res, 400, "unsupported_grant_type", "Solo authorization_code y refresh_token.");
163
+ }
164
+ catch (e) {
165
+ logger.error(`OAuth /token: ${e.message}`);
166
+ oauthError(res, 500, "server_error", "Error interno al emitir el token.");
167
+ }
168
+ });
169
+ app.post("/revoke", cors, form, express.json({ limit: "16kb" }), async (req, res) => {
170
+ const token = req.body?.token;
171
+ if (token)
172
+ await store.revokeToken(token);
173
+ // RFC 7009: se contesta 200 aunque el token no exista.
174
+ res.sendStatus(200);
175
+ });
176
+ // Volver a iniciar sesión cuando vence la sesión del aula: no emite tokens, solo renueva la sesión.
177
+ app.get("/login", (_req, res) => void res.type("html").send(loginPage({ action: "/login" })));
178
+ app.post("/login", sameOrigin, form, async (req, res) => {
179
+ const account = await login(req, (error) => res.type("html").send(loginPage({ action: "/login", error })));
180
+ if (!account)
181
+ return;
182
+ logger.info(`OAuth: ${account.username} renovó su sesión del aula.`);
183
+ res.type("html").send(page("Listo", "<p>Ya renovamos tu sesión del aula. Volvé al chat y pedile al agente que reintente.</p>"));
184
+ });
185
+ /** Entra al aula con lo que mandó el formulario; si falla, vuelve a mostrarlo con el error. */
186
+ async function login(req, retry) {
187
+ if (!limiter.allow(req.ip ?? "desconocida")) {
188
+ retry("Demasiados intentos. Esperá unos minutos y probá de nuevo.");
189
+ return null;
190
+ }
191
+ const username = String(req.body?.usuario ?? "").trim();
192
+ const password = String(req.body?.contrasena ?? "");
193
+ if (!username || !password) {
194
+ retry("Completá usuario y contraseña.");
195
+ return null;
196
+ }
197
+ try {
198
+ return await linkAulaAccount(store, { username, password });
199
+ }
200
+ catch (e) {
201
+ if (e instanceof InvalidCredentialsError)
202
+ retry("La UTN rechazó el usuario o la contraseña.");
203
+ else {
204
+ logger.error(`Login de ${username}: ${e.message}`);
205
+ retry("No pudimos entrar al aula. Puede que esté caída: probá de nuevo en unos minutos.");
206
+ }
207
+ return null;
208
+ }
209
+ }
210
+ return getOAuthProtectedResourceMetadataUrl(new URL(resource));
211
+ }
212
+ async function issueTokens(store, userId, clientId) {
213
+ const accessToken = await store.issueToken(userId, { kind: "access", clientId, ttlSeconds: ACCESS_TTL });
214
+ const refreshToken = await store.issueToken(userId, { kind: "refresh", clientId, ttlSeconds: REFRESH_TTL });
215
+ return { access_token: accessToken, token_type: "Bearer", expires_in: ACCESS_TTL, refresh_token: refreshToken };
216
+ }
217
+ const pkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
218
+ /** HTTPS, http solo en loopback (clientes de escritorio) o un esquema propio de una app (`cursor://`). */
219
+ function isAllowedRedirect(uri) {
220
+ if (typeof uri !== "string")
221
+ return false;
222
+ try {
223
+ const url = new URL(uri);
224
+ if (FORBIDDEN_SCHEMES.has(url.protocol) || url.hash)
225
+ return false;
226
+ if (url.protocol === "http:")
227
+ return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
228
+ return true;
229
+ }
230
+ catch {
231
+ return false;
232
+ }
233
+ }
234
+ /** Los endpoints que llaman los clientes desde el navegador (como el MCP Inspector) aceptan cualquier origen. */
235
+ const cors = (_req, res, next) => {
236
+ res.setHeader("Access-Control-Allow-Origin", "*");
237
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
238
+ res.setHeader("Access-Control-Allow-Headers", "content-type, authorization, mcp-protocol-version");
239
+ next();
240
+ };
241
+ function redirectWith(res, base, params) {
242
+ const url = new URL(base);
243
+ for (const [k, v] of Object.entries(params))
244
+ if (v !== undefined)
245
+ url.searchParams.set(k, v);
246
+ res.redirect(302, url.toString());
247
+ }
248
+ function oauthError(res, status, error, description) {
249
+ res.status(status).json({ error, error_description: description });
250
+ }
251
+ function errorPage(res, status, title, message) {
252
+ res.status(status).type("html").send(page(title, `<p>${escapeHtml(message)}</p>`));
253
+ }
254
+ function deniedPage(res, reason, url) {
255
+ const link = url ? `<p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>` : "";
256
+ res.status(403).type("html").send(page("Sin acceso", `<p>${escapeHtml(reason)}</p>${link}`));
257
+ }
258
+ function loginPage(o) {
259
+ const intro = o.pendingId
260
+ ? `<p>${escapeHtml(o.clientName ?? "Una app")} quiere usar UTN-MCP con tu Aula Virtual. Entrá con tu usuario de la UTN para conectarla.</p>`
261
+ : "<p>Tu sesión del aula venció. Entrá con tu usuario de la UTN para renovarla.</p>";
262
+ const error = o.error ? `<p class=error>${escapeHtml(o.error)}</p>` : "";
263
+ const hidden = o.pendingId ? `<input type="hidden" name="pedido" value="${escapeHtml(o.pendingId)}">` : "";
264
+ return page("Entrar con la UTN", intro +
265
+ error +
266
+ `<form method="post" action="${o.action}">${hidden}` +
267
+ `<label>Usuario<input name="usuario" autocomplete="username" autocapitalize="none" required></label>` +
268
+ `<label>Contraseña<input name="contrasena" type="password" autocomplete="current-password" required></label>` +
269
+ `<button type="submit">Entrar</button></form>` +
270
+ "<p class=nota>La contraseña se usa una sola vez para entrar al aula y no se guarda. " +
271
+ '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");
272
+ }
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.7.0",
11
11
  });
12
12
  registerAulaVirtualTools(server, aula, files);
13
13
  return server;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Los colores del sitio del proyecto: papel milimetrado, tinta y un azul de acento. Oscuro salvo que
3
+ * el sistema pida claro. Sin fuentes externas: la página de login recibe contraseñas.
4
+ */
5
+ const STYLE = `
6
+ :root{--paper:#0a0b0d;--ink:#e9e7e0;--muted:#8b8a84;--line:rgb(233 231 224/.055);--line-faint:rgb(233 231 224/.025);
7
+ --line-strong:rgb(233 231 224/.16);--accent:#6b93ff;--err:#f87171;--panel:rgb(20 21 24/.6);color-scheme:dark}
8
+ @media(prefers-color-scheme:light){:root{--paper:#f3f2ec;--ink:#121212;--muted:#6d6c66;--line:rgb(18 18 18/.07);
9
+ --line-faint:rgb(18 18 18/.035);--line-strong:rgb(18 18 18/.2);--accent:#1f4fd6;--err:#b91c1c;--panel:rgb(255 255 255/.55);color-scheme:light}}
10
+ *{box-sizing:border-box}
11
+ body{margin:0;min-height:100vh;color:var(--ink);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
12
+ background-color:var(--paper);background-image:linear-gradient(var(--line) 1px,transparent 1px),
13
+ linear-gradient(90deg,var(--line) 1px,transparent 1px),linear-gradient(var(--line-faint) 1px,transparent 1px),
14
+ linear-gradient(90deg,var(--line-faint) 1px,transparent 1px);background-size:80px 80px,80px 80px,16px 16px,16px 16px;
15
+ background-position:-1px -1px}
16
+ .mono,label,button,.kicker,header{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
17
+ header{display:flex;align-items:center;gap:.5rem;height:3.5rem;padding:0 1rem;border-bottom:1px solid var(--line);
18
+ font-size:13px;letter-spacing:-.01em;background:color-mix(in srgb,var(--paper) 75%,transparent)}
19
+ header svg{height:1.5rem;width:auto}
20
+ main{max-width:28rem;margin:3rem auto;padding:0 1rem}
21
+ .card{position:relative;border:1px solid var(--line-strong);background:var(--panel);padding:1.5rem}
22
+ .card::before,.card::after{content:"";position:absolute;width:9px;height:9px;border-color:var(--ink);opacity:.55}
23
+ .card::before{top:-1px;left:-1px;border-top:1px solid;border-left:1px solid}
24
+ .card::after{bottom:-1px;right:-1px;border-bottom:1px solid;border-right:1px solid}
25
+ .kicker{margin:0 0 .5rem;font-size:10px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted)}
26
+ h1{margin:0 0 1rem;font-size:1.5rem;font-weight:600;letter-spacing:-.02em;line-height:1.2}
27
+ p{margin:.75rem 0}
28
+ a{color:var(--accent);text-underline-offset:4px}
29
+ form{display:flex;flex-direction:column;gap:1rem;margin:1.5rem 0}
30
+ label{display:flex;flex-direction:column;gap:.4rem;font-size:10px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted)}
31
+ input{font:inherit;font-size:15px;letter-spacing:normal;text-transform:none;color:var(--ink);background:transparent;
32
+ border:1px solid var(--line-strong);border-radius:0;padding:.65rem .75rem}
33
+ input:focus{outline:none;border-color:var(--accent)}
34
+ input[type=file]{padding:.5rem}
35
+ button{font-size:12px;text-transform:uppercase;letter-spacing:.14em;border:0;border-radius:0;padding:.8rem 1rem;
36
+ background:var(--ink);color:var(--paper);cursor:pointer;transition:background .15s}
37
+ button:hover{background:var(--accent)}
38
+ .nota{font-size:13px;color:var(--muted)}
39
+ .error{border-left:2px solid var(--err);padding-left:.75rem;color:var(--err)}
40
+ ul{padding-left:1.2rem}
41
+ ::selection{background:var(--accent);color:var(--paper)}
42
+ `.replace(/\n/g, "");
43
+ /** El emblema del proyecto: el de la UTN con "MCP" en lugar de "UTN". */
44
+ const MARK = '<svg viewBox="0 0 100 132" fill="none" aria-hidden="true"><g stroke="currentColor" stroke-width="11">' +
45
+ '<path d="M50 0v100M0 50h100"/><path d="M19 0v19a11 11 0 0 0 11 11h40a11 11 0 0 0 11-11V0"/>' +
46
+ '<path d="M19 100V81a11 11 0 0 1 11-11h40a11 11 0 0 1 11 11v19"/></g>' +
47
+ '<text x="50" y="131" text-anchor="middle" textLength="72" lengthAdjust="spacingAndGlyphs" ' +
48
+ 'font-family="Arial,Helvetica,sans-serif" font-weight="900" font-size="29" fill="currentColor">MCP</text></svg>';
49
+ /**
50
+ * Página mínima con el estilo del sitio del proyecto: se abre desde el celu, así que nada más que lo necesario.
51
+ * `kicker` es la etiqueta chica arriba del título.
52
+ */
53
+ export function page(title, body, kicker = "UTN-MCP") {
54
+ return (`<!doctype html><html lang="es"><head><meta charset="utf-8">` +
55
+ `<meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)} · UTN-MCP</title>` +
56
+ `<style>${STYLE}</style></head><body>` +
57
+ `<header>${MARK}utn·mcp</header>` +
58
+ `<main><div class="card"><p class="kicker">${escapeHtml(kicker)}</p><h1>${escapeHtml(title)}</h1>${body}</div></main>` +
59
+ `</body></html>`);
60
+ }
61
+ export const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -2,28 +2,35 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:
2
2
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
4
  import { storeFile, storeKeyFile } from "../paths.js";
5
- const SCHEMA = `
6
- CREATE TABLE IF NOT EXISTS users (
7
- id INTEGER PRIMARY KEY,
8
- utn_username TEXT NOT NULL UNIQUE,
9
- created_at INTEGER NOT NULL
10
- );
11
- CREATE TABLE IF NOT EXISTS tokens (
12
- hash TEXT PRIMARY KEY,
13
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
14
- kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')),
15
- client_id TEXT,
16
- expires_at INTEGER,
17
- created_at INTEGER NOT NULL
18
- );
19
- CREATE INDEX IF NOT EXISTS tokens_user ON tokens(user_id);
20
- CREATE TABLE IF NOT EXISTS platform_sessions (
21
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
22
- platform TEXT NOT NULL,
23
- data BLOB NOT NULL,
24
- updated_at INTEGER NOT NULL,
25
- PRIMARY KEY (user_id, platform)
26
- );
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS users (
7
+ id INTEGER PRIMARY KEY,
8
+ utn_username TEXT NOT NULL UNIQUE,
9
+ email TEXT,
10
+ created_at INTEGER NOT NULL
11
+ );
12
+ CREATE TABLE IF NOT EXISTS tokens (
13
+ hash TEXT PRIMARY KEY,
14
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
15
+ kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')),
16
+ client_id TEXT,
17
+ expires_at INTEGER,
18
+ created_at INTEGER NOT NULL
19
+ );
20
+ CREATE INDEX IF NOT EXISTS tokens_user ON tokens(user_id);
21
+ CREATE TABLE IF NOT EXISTS platform_sessions (
22
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
23
+ platform TEXT NOT NULL,
24
+ data BLOB NOT NULL,
25
+ updated_at INTEGER NOT NULL,
26
+ PRIMARY KEY (user_id, platform)
27
+ );
28
+ CREATE TABLE IF NOT EXISTS oauth_clients (
29
+ client_id TEXT PRIMARY KEY,
30
+ client_name TEXT,
31
+ redirect_uris TEXT NOT NULL,
32
+ created_at INTEGER NOT NULL
33
+ );
27
34
  `;
28
35
  /** Bytes de la clave AES-256. */
29
36
  const KEY_BYTES = 32;
@@ -59,16 +66,28 @@ export class SqliteStore {
59
66
  // WAL: el login escribe mientras el servidor lee, desde otro proceso.
60
67
  db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
61
68
  db.exec(SCHEMA);
69
+ migrate(db);
62
70
  return new SqliteStore(db, await loadKey());
63
71
  }
64
- async findOrCreateUser(utnUsername) {
72
+ async findOrCreateUser(utnUsername, email) {
65
73
  const username = utnUsername.trim().toLowerCase();
66
74
  this.db
67
75
  .prepare("INSERT INTO users (utn_username, created_at) VALUES (?, ?) ON CONFLICT DO NOTHING")
68
76
  .run(username, now());
77
+ if (email) {
78
+ this.db.prepare("UPDATE users SET email = ? WHERE utn_username = ?").run(email.trim().toLowerCase(), username);
79
+ }
69
80
  const row = this.db.prepare("SELECT id FROM users WHERE utn_username = ?").get(username);
70
81
  return row.id;
71
82
  }
83
+ async findUserByEmail(email) {
84
+ const row = this.db.prepare("SELECT id, utn_username, email FROM users WHERE email = ?").get(email.trim().toLowerCase());
85
+ return row ? { id: row.id, utnUsername: row.utn_username, email: row.email } : null;
86
+ }
87
+ async findUser(userId) {
88
+ const row = this.db.prepare("SELECT id, utn_username, email FROM users WHERE id = ?").get(userId);
89
+ return row ? { id: row.id, utnUsername: row.utn_username, email: row.email } : null;
90
+ }
72
91
  async issueToken(userId, options) {
73
92
  const token = randomBytes(32).toString("base64url");
74
93
  const createdAt = now();
@@ -99,6 +118,37 @@ export class SqliteStore {
99
118
  "ON CONFLICT (user_id, platform) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at")
100
119
  .run(userId, platform, this.encrypt(data), now());
101
120
  }
121
+ async saveClient(client) {
122
+ this.db
123
+ .prepare("INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at) VALUES (?, ?, ?, ?)")
124
+ .run(client.clientId, client.clientName, JSON.stringify(client.redirectUris), now());
125
+ }
126
+ async findClient(clientId) {
127
+ const row = this.db
128
+ .prepare("SELECT client_id, client_name, redirect_uris FROM oauth_clients WHERE client_id = ?")
129
+ .get(clientId);
130
+ if (!row)
131
+ return null;
132
+ return { clientId: row.client_id, clientName: row.client_name, redirectUris: JSON.parse(row.redirect_uris) };
133
+ }
134
+ async sessionUpdatedAt(userId, platform) {
135
+ const row = this.db
136
+ .prepare("SELECT updated_at FROM platform_sessions WHERE user_id = ? AND platform = ?")
137
+ .get(userId, platform);
138
+ return row?.updated_at ?? null;
139
+ }
140
+ async listConnectedClients(userId) {
141
+ const rows = this.db
142
+ .prepare("SELECT t.client_id, c.client_name, MAX(t.created_at) AS last_token_at FROM tokens t " +
143
+ "LEFT JOIN oauth_clients c ON c.client_id = t.client_id " +
144
+ "WHERE t.user_id = ? AND t.client_id IS NOT NULL AND (t.expires_at IS NULL OR t.expires_at > ?) " +
145
+ "GROUP BY t.client_id ORDER BY last_token_at DESC")
146
+ .all(userId, now());
147
+ return rows.map((r) => ({ clientId: r.client_id, clientName: r.client_name, lastTokenAt: r.last_token_at }));
148
+ }
149
+ async revokeClient(userId, clientId) {
150
+ this.db.prepare("DELETE FROM tokens WHERE user_id = ? AND client_id = ?").run(userId, clientId);
151
+ }
102
152
  /** iv + tag + texto cifrado, en un solo blob. */
103
153
  encrypt(plain) {
104
154
  const iv = randomBytes(IV_BYTES);
@@ -118,6 +168,12 @@ export class SqliteStore {
118
168
  }
119
169
  }
120
170
  }
171
+ /** Lo que `CREATE TABLE IF NOT EXISTS` no agrega a una base que ya existía. */
172
+ function migrate(db) {
173
+ const columns = db.prepare("PRAGMA table_info(users)").all();
174
+ if (!columns.some((c) => c.name === "email"))
175
+ db.exec("ALTER TABLE users ADD COLUMN email TEXT");
176
+ }
121
177
  /**
122
178
  * UTN_MCP_KEY (32 bytes en base64) en el servidor hosteado, donde la clave no tiene que estar en el
123
179
  * mismo disco que la base. En local, si no está, se genera una en la carpeta de configuración.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utn-mcp",
3
- "version": "0.6.9",
3
+ "version": "0.7.0",
4
4
  "description": "Este es el Model Context Protocol server NO OFICIAL de la UTN para buscar informacion o archivos en el aula virtual.",
5
5
  "main": "build/index.js",
6
6
  "bin": {