utn-mcp 0.6.7 → 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 +180 -146
- package/build/aula-virtual/client/client.js +55 -0
- package/build/aula-virtual/client/link.js +27 -0
- package/build/aula-virtual/client/moodle-api.js +34 -0
- package/build/aula-virtual/client/session.js +0 -1
- package/build/aula-virtual/client/stored-session.js +65 -0
- package/build/aula-virtual/format/activities.js +1 -0
- package/build/aula-virtual/format/submission.js +214 -0
- package/build/aula-virtual/login.js +23 -4
- package/build/aula-virtual/tools.js +101 -1
- package/build/http.js +139 -8
- package/build/index.js +45 -9
- package/build/oauth/access.js +52 -0
- package/build/oauth/admin.js +48 -0
- package/build/oauth/expiring.js +57 -0
- package/build/oauth/server.js +272 -0
- package/build/server.js +7 -4
- package/build/shared/pages.js +61 -0
- package/build/shared/paths.js +4 -0
- package/build/shared/store/session-store.js +1 -0
- package/build/shared/store/sqlite-store.js +201 -0
- package/build/shared/uploads.js +80 -0
- package/package.json +1 -1
|
@@ -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
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Un servidor MCP para un usuario. Las entradas STDIO y HTTP lo crean por conexión o por request.
|
|
5
|
+
* `files`: de dónde salen los archivos que el usuario sube a una plataforma.
|
|
6
|
+
*/
|
|
7
|
+
export function createServer(aula, files) {
|
|
5
8
|
const server = new McpServer({
|
|
6
9
|
name: "UTN-MCP",
|
|
7
|
-
version: "0.
|
|
10
|
+
version: "0.7.0",
|
|
8
11
|
});
|
|
9
|
-
registerAulaVirtualTools(server, aula);
|
|
12
|
+
registerAulaVirtualTools(server, aula, files);
|
|
10
13
|
return server;
|
|
11
14
|
}
|
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
package/build/shared/paths.js
CHANGED
|
@@ -11,3 +11,7 @@ export function configDir() {
|
|
|
11
11
|
}
|
|
12
12
|
/** Token de Moodle + cookies del SSO del modo local. */
|
|
13
13
|
export const sessionFile = () => join(configDir(), "session.json");
|
|
14
|
+
/** Base de usuarios, tokens y sesiones del modo HTTP: UTN_MCP_DB o un SQLite en la carpeta de configuración. */
|
|
15
|
+
export const storeFile = () => process.env.UTN_MCP_DB ?? join(configDir(), "utn-mcp.db");
|
|
16
|
+
/** Clave con la que se cifran las sesiones en la base, si no viene en UTN_MCP_KEY. */
|
|
17
|
+
export const storeKeyFile = () => join(configDir(), "store.key");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|