utn-mcp 0.2.1 → 0.6.9
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 +22 -0
- package/build/aula-virtual/client/client.js +181 -3
- package/build/aula-virtual/client/moodle-api.js +47 -13
- package/build/aula-virtual/client/session.js +0 -1
- package/build/aula-virtual/client/stored-session.js +55 -0
- package/build/aula-virtual/format/actions.js +159 -0
- package/build/aula-virtual/format/activities.js +102 -0
- package/build/aula-virtual/format/address.js +52 -0
- package/build/aula-virtual/format/fields.js +14 -0
- package/build/aula-virtual/format/files.js +89 -0
- package/build/aula-virtual/format/navigate.js +143 -0
- package/build/aula-virtual/format/submission.js +214 -0
- package/build/aula-virtual/login.js +30 -4
- package/build/aula-virtual/tools.js +291 -13
- package/build/http.js +121 -3
- package/build/index.js +23 -11
- package/build/server.js +7 -4
- package/build/shared/documents/index.js +46 -46
- package/build/shared/documents/office.js +63 -63
- package/build/shared/documents/pdf.js +80 -60
- package/build/shared/documents/png.js +20 -20
- package/build/shared/documents/zip.js +49 -48
- package/build/shared/html.js +15 -2
- package/build/shared/paths.js +4 -0
- package/build/shared/store/session-store.js +1 -0
- package/build/shared/store/sqlite-store.js +145 -0
- package/build/shared/uploads.js +80 -0
- package/package.json +1 -1
- package/build/aula-virtual/address.js +0 -39
- package/build/aula-virtual/files.js +0 -89
- package/build/aula-virtual/navigate.js +0 -144
- /package/build/aula-virtual/{types.js → client/types.js} +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { getSiteInfo } from "./client/client.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { addCalendarEvent, answerChoice, deleteForumPost, editForumPost, postToForum, sendMessage, } from "./format/actions.js";
|
|
4
|
+
import { ROOT } from "./format/address.js";
|
|
5
|
+
import { readAulaFile } from "./format/files.js";
|
|
6
|
+
import { open } from "./format/navigate.js";
|
|
7
|
+
import { createUploadLink, submitAssignment } from "./format/submission.js";
|
|
6
8
|
// JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
|
|
7
9
|
const json = (data) => ({
|
|
8
10
|
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
@@ -11,8 +13,13 @@ const failure = (e) => ({
|
|
|
11
13
|
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
12
14
|
isError: true,
|
|
13
15
|
});
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
/** Para que el cliente distinga las tools que leen de las que cambian algo en el aula. */
|
|
17
|
+
const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
|
|
18
|
+
export function registerAulaVirtualTools(server, session, files) {
|
|
19
|
+
server.registerTool("aula_get_site_info", {
|
|
20
|
+
description: "Datos del usuario logueado en el aula virtual de la UTN FRBA y del sitio Moodle.",
|
|
21
|
+
annotations: READ_ONLY,
|
|
22
|
+
}, async () => {
|
|
16
23
|
try {
|
|
17
24
|
const { sitename, fullname, username, userid, release } = await getSiteInfo(session);
|
|
18
25
|
return json({ sitename, fullname, username, userid, release });
|
|
@@ -24,9 +31,10 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
24
31
|
server.registerTool("aula_list_courses", {
|
|
25
32
|
description: "Lista los cursos (materias) en los que está inscripto el usuario. Es el punto de entrada: " +
|
|
26
33
|
"cada curso viene con su `direccion`, que se le pasa a aula_open para entrar.",
|
|
34
|
+
annotations: READ_ONLY,
|
|
27
35
|
}, async () => {
|
|
28
36
|
try {
|
|
29
|
-
return json(await open(session,
|
|
37
|
+
return json(await open(session, ROOT));
|
|
30
38
|
}
|
|
31
39
|
catch (e) {
|
|
32
40
|
return failure(e);
|
|
@@ -36,6 +44,11 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
36
44
|
description: "Entra a una dirección del aula virtual y devuelve lo que hay ahí: un curso con sus secciones " +
|
|
37
45
|
"y módulos, un módulo con sus archivos y fechas, un foro con el índice de sus discusiones, o " +
|
|
38
46
|
"una discusión con sus mensajes completos.\n\n" +
|
|
47
|
+
"Según el tipo de módulo trae además: en una tarea (assign), `entrega` con el estado, el " +
|
|
48
|
+
"vencimiento, lo que se subió y la devolución con la nota; en una elección (choice) o " +
|
|
49
|
+
"selección de grupos (choicegroup), `eleccion` con las opciones y sus ids; en un foro, " +
|
|
50
|
+
"`puedeAbrirHilo`; en una discusión, el `id`, el `autorId` y las `acciones` permitidas de " +
|
|
51
|
+
"cada publicación. Es lo que piden las tools que escriben en el aula.\n\n" +
|
|
39
52
|
"No hace falta armar la dirección: cada respuesta trae las direcciones de lo que hay adentro " +
|
|
40
53
|
"(`direccion`) y la de un nivel más arriba (`padre`), así que se navega pasando de vuelta una " +
|
|
41
54
|
"de esas. Para empezar, usar aula_list_courses o la dirección \"/\".\n\n" +
|
|
@@ -57,6 +70,7 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
57
70
|
.describe("Solo para foros: página del índice de discusiones, de 50 en 50. La respuesta avisa " +
|
|
58
71
|
"con `hayMasDiscusiones` cuando hay otra."),
|
|
59
72
|
}),
|
|
73
|
+
annotations: READ_ONLY,
|
|
60
74
|
}, async ({ direccion, pagina }) => {
|
|
61
75
|
try {
|
|
62
76
|
return json(await open(session, direccion, pagina));
|
|
@@ -82,20 +96,21 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
82
96
|
.optional()
|
|
83
97
|
.describe("Número de página, diapositiva o fragmento desde el cual leer. Por defecto, 1."),
|
|
84
98
|
}),
|
|
99
|
+
annotations: READ_ONLY,
|
|
85
100
|
}, async ({ url, desde }) => {
|
|
86
101
|
try {
|
|
87
|
-
const {
|
|
88
|
-
const
|
|
102
|
+
const { parts, ...fileRead } = await readAulaFile(session, url, desde);
|
|
103
|
+
const label = (n, title) => `--- ${fileRead.unidad} ${n} de ${fileRead.total}${title ? ` (${title})` : ""} ---`;
|
|
89
104
|
return {
|
|
90
105
|
content: [
|
|
91
|
-
{ type: "text", text: JSON.stringify(
|
|
92
|
-
...
|
|
93
|
-
? [{ type: "text", text: `${
|
|
106
|
+
{ type: "text", text: JSON.stringify(fileRead) },
|
|
107
|
+
...parts.flatMap((p) => "text" in p
|
|
108
|
+
? [{ type: "text", text: `${label(p.number, p.title)}\n${p.text}` }]
|
|
94
109
|
: [
|
|
95
|
-
{ type: "text", text:
|
|
110
|
+
{ type: "text", text: label(p.number, p.title) },
|
|
96
111
|
{
|
|
97
112
|
type: "image",
|
|
98
|
-
data: Buffer.from(p.
|
|
113
|
+
data: Buffer.from(p.image).toString("base64"),
|
|
99
114
|
mimeType: p.mimeType,
|
|
100
115
|
},
|
|
101
116
|
]),
|
|
@@ -106,4 +121,267 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
106
121
|
return failure(e);
|
|
107
122
|
}
|
|
108
123
|
});
|
|
124
|
+
// --- Escritura: lo que sigue cambia algo en el aula, y casi todo queda a la vista de otras personas. ---
|
|
125
|
+
const CONFIRM_FIRST = "\n\nAntes de llamarla, mostrale al usuario exactamente lo que se va a hacer y esperá que confirme.";
|
|
126
|
+
server.registerTool("aula_forum_post", {
|
|
127
|
+
description: "Publica en un foro del aula virtual. Con la `direccion` de un foro abre un hilo nuevo (hace " +
|
|
128
|
+
"falta `asunto`); con la de una discusión responde en ella, a la publicación `respuestaA` o, " +
|
|
129
|
+
"si no se indica, a la primera.\n\n" +
|
|
130
|
+
"Lo que se publica lo ven docentes y compañeros, y a muchos les llega por mail. Antes de abrir " +
|
|
131
|
+
"un hilo, mirá `puedeAbrirHilo` del foro; antes de responder, que la publicación tenga " +
|
|
132
|
+
'"responder" en `acciones`.' +
|
|
133
|
+
CONFIRM_FIRST,
|
|
134
|
+
inputSchema: z.object({
|
|
135
|
+
direccion: z
|
|
136
|
+
.string()
|
|
137
|
+
.describe('Dirección de un foro o de una discusión. Ej: "/curso/691/mod/34831" o ' +
|
|
138
|
+
'"/curso/691/mod/34831/disc/416676".'),
|
|
139
|
+
mensaje: z
|
|
140
|
+
.string()
|
|
141
|
+
.min(1)
|
|
142
|
+
.describe("Texto del mensaje, en texto plano. Una línea en blanco separa párrafos. " +
|
|
143
|
+
'Ej: "Hola, ¿el parcial incluye la unidad 4?"'),
|
|
144
|
+
asunto: z
|
|
145
|
+
.string()
|
|
146
|
+
.optional()
|
|
147
|
+
.describe('Obligatorio para abrir un hilo. En una respuesta, por defecto "Re: <asunto>". ' +
|
|
148
|
+
'Ej: "Consulta sobre el parcial".'),
|
|
149
|
+
respuestaA: z
|
|
150
|
+
.number()
|
|
151
|
+
.int()
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("Solo al responder: `id` de la publicación a la que se responde, de aula_open. Ej: 768118."),
|
|
154
|
+
}),
|
|
155
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
156
|
+
}, async ({ direccion, mensaje, asunto, respuestaA }) => {
|
|
157
|
+
try {
|
|
158
|
+
return json(await postToForum(session, direccion, mensaje, asunto, respuestaA));
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
return failure(e);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
server.registerTool("aula_forum_edit_post", {
|
|
165
|
+
description: "Edita una publicación propia de un foro y reemplaza el mensaje completo. Solo se puede si la " +
|
|
166
|
+
'publicación tiene "editar" en `acciones`: Moodle deja editar por un rato después de publicar.' +
|
|
167
|
+
CONFIRM_FIRST,
|
|
168
|
+
inputSchema: z.object({
|
|
169
|
+
direccion: z
|
|
170
|
+
.string()
|
|
171
|
+
.describe('Dirección de la discusión donde está la publicación. Ej: "/curso/691/mod/34831/disc/416676".'),
|
|
172
|
+
id: z.number().int().describe("`id` de la publicación, de aula_open. Ej: 768118."),
|
|
173
|
+
mensaje: z
|
|
174
|
+
.string()
|
|
175
|
+
.min(1)
|
|
176
|
+
.describe('El mensaje nuevo completo, en texto plano; reemplaza al anterior. Ej: "Ya lo resolví, gracias."'),
|
|
177
|
+
asunto: z
|
|
178
|
+
.string()
|
|
179
|
+
.optional()
|
|
180
|
+
.describe('Asunto nuevo. Si no se pasa, queda el que tenía. Ej: "Consulta sobre el parcial (resuelta)".'),
|
|
181
|
+
}),
|
|
182
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
183
|
+
}, async ({ direccion, id, mensaje, asunto }) => {
|
|
184
|
+
try {
|
|
185
|
+
return json(await editForumPost(session, direccion, id, mensaje, asunto));
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
return failure(e);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
server.registerTool("aula_forum_delete_post", {
|
|
192
|
+
description: "Borra una publicación propia de un foro. No se puede deshacer. Solo se puede si tiene " +
|
|
193
|
+
'"borrar" en `acciones`. Si es la primera publicación de la discusión, se borra la discusión ' +
|
|
194
|
+
"entera." +
|
|
195
|
+
CONFIRM_FIRST,
|
|
196
|
+
inputSchema: z.object({
|
|
197
|
+
direccion: z
|
|
198
|
+
.string()
|
|
199
|
+
.describe('Dirección de la discusión donde está la publicación. Ej: "/curso/691/mod/34831/disc/416676".'),
|
|
200
|
+
id: z.number().int().describe("`id` de la publicación, de aula_open. Ej: 768118."),
|
|
201
|
+
}),
|
|
202
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
203
|
+
}, async ({ direccion, id }) => {
|
|
204
|
+
try {
|
|
205
|
+
return json(await deleteForumPost(session, direccion, id));
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
return failure(e);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
server.registerTool("aula_choice_answer", {
|
|
212
|
+
description: 'Responde una elección (choice: encuestas rápidas como "¿venís al parcial?") o una selección ' +
|
|
213
|
+
"de grupos (choicegroup: anotarse en un grupo o comisión). Deja elegidas exactamente las " +
|
|
214
|
+
"`opciones` pasadas y reemplaza la respuesta anterior; con una lista vacía la borra.\n\n" +
|
|
215
|
+
"Las opciones salen de `eleccion` al abrir el módulo con aula_open. Muchas no dejan cambiar " +
|
|
216
|
+
"la respuesta una vez dada (`puedeCambiar: false`), y las opciones `deshabilitada` están " +
|
|
217
|
+
"completas. Devuelve cómo quedó la elección." +
|
|
218
|
+
CONFIRM_FIRST,
|
|
219
|
+
inputSchema: z.object({
|
|
220
|
+
direccion: z.string().describe('Dirección del módulo. Ej: "/curso/29487/mod/1729113".'),
|
|
221
|
+
opciones: z
|
|
222
|
+
.array(z.number().int())
|
|
223
|
+
.describe("`id` de las opciones a elegir. Ej: [31076]. Varias solo si la actividad lo permite."),
|
|
224
|
+
}),
|
|
225
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
226
|
+
}, async ({ direccion, opciones }) => {
|
|
227
|
+
try {
|
|
228
|
+
return json(await answerChoice(session, direccion, opciones));
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return failure(e);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
server.registerTool("aula_send_message", {
|
|
235
|
+
description: "Manda un mensaje privado por la mensajería del aula virtual a otro usuario, docente o " +
|
|
236
|
+
"compañero. El `usuarioId` sale del `autorId` de un foro o una discusión abiertos con " +
|
|
237
|
+
"aula_open: no lo inventes ni lo deduzcas del nombre. La respuesta trae el nombre del " +
|
|
238
|
+
"destinatario." +
|
|
239
|
+
CONFIRM_FIRST +
|
|
240
|
+
" Incluí a quién va dirigido.",
|
|
241
|
+
inputSchema: z.object({
|
|
242
|
+
usuarioId: z.number().int().describe("Id del destinatario, del `autorId` de aula_open. Ej: 1066."),
|
|
243
|
+
mensaje: z
|
|
244
|
+
.string()
|
|
245
|
+
.min(1)
|
|
246
|
+
.describe('Texto del mensaje, en texto plano. Ej: "Profe, ¿puedo rendir el recuperatorio el viernes?"'),
|
|
247
|
+
}),
|
|
248
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
249
|
+
}, async ({ usuarioId, mensaje }) => {
|
|
250
|
+
try {
|
|
251
|
+
return json(await sendMessage(session, usuarioId, mensaje));
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
return failure(e);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
// En STDIO el servidor lee el archivo del disco del usuario; en HTTP no puede, porque puede estar en
|
|
258
|
+
// otra máquina: los archivos llegan por un link de subida, que usa el agente o el usuario.
|
|
259
|
+
const newFiles = files.local
|
|
260
|
+
? "Los archivos nuevos se pasan en `archivos` con su ruta absoluta en la compu del usuario."
|
|
261
|
+
: "Los archivos nuevos no van en la llamada: primero pedí un link con aula_assign_upload_link, " +
|
|
262
|
+
"subilos ahí y después pasá su id en `subida`.";
|
|
263
|
+
if (!files.local) {
|
|
264
|
+
const { inbox, owner } = files;
|
|
265
|
+
server.registerTool("aula_assign_upload_link", {
|
|
266
|
+
description: "Genera un link de un solo uso para subir los archivos de una entrega, que después se " +
|
|
267
|
+
"entregan con aula_assign_submit pasando el `subida` que devuelve. Vence en 30 minutos.\n\n" +
|
|
268
|
+
"Cómo subirlos:\n" +
|
|
269
|
+
"- Si podés ejecutar comandos y tenés el archivo (por ejemplo, uno que el usuario adjuntó o " +
|
|
270
|
+
"que está en su compu), subilo vos con el comando de `conTerminal`, cambiando la ruta. Va un " +
|
|
271
|
+
'`-F "archivos=@..."` por archivo, todos en el mismo comando. Responde JSON con lo recibido.\n' +
|
|
272
|
+
"- Si no, pasale el `url` al usuario: se abre en el navegador, también en el celu, y elige " +
|
|
273
|
+
"los archivos ahí. Esperá a que te diga que los subió.\n\n" +
|
|
274
|
+
"Subir al link no entrega nada; una subida nueva al mismo link reemplaza la anterior. " +
|
|
275
|
+
"`acepta` trae los límites de la tarea.",
|
|
276
|
+
inputSchema: z.object({
|
|
277
|
+
direccion: z
|
|
278
|
+
.string()
|
|
279
|
+
.describe('Dirección de la tarea, de aula_open. Ej: "/curso/29487/mod/1729138".'),
|
|
280
|
+
}),
|
|
281
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
282
|
+
}, async ({ direccion }) => {
|
|
283
|
+
try {
|
|
284
|
+
return json(await createUploadLink(session, direccion, inbox, owner));
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
return failure(e);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const submitSchema = z.object({
|
|
292
|
+
direccion: z.string().describe('Dirección de la tarea, de aula_open. Ej: "/curso/29487/mod/1729138".'),
|
|
293
|
+
archivos: z
|
|
294
|
+
.array(z.string())
|
|
295
|
+
.optional()
|
|
296
|
+
.describe(files.local
|
|
297
|
+
? "Lista final de archivos. Cada uno es la ruta absoluta de un archivo en la compu del " +
|
|
298
|
+
"usuario o la `url` de uno que ya está en la entrega. Lista vacía: quita todos. " +
|
|
299
|
+
'Ej: ["C:\\Users\\ana\\Documents\\TP3.pdf"].'
|
|
300
|
+
: "Lista final de los archivos que ya están en la entrega, por su `url` de aula_open; los " +
|
|
301
|
+
"de `subida` se suman. Lista vacía: quita los que estaban. " +
|
|
302
|
+
'Ej: ["https://aulasvirtuales.frba.utn.edu.ar/pluginfile.php/123/assignsubmission_file/submission_files/456/TP3.pdf"].'),
|
|
303
|
+
texto: z
|
|
304
|
+
.string()
|
|
305
|
+
.optional()
|
|
306
|
+
.describe("Texto en línea completo, en texto plano; reemplaza al anterior. Una línea en blanco " +
|
|
307
|
+
'separa párrafos. Ej: "Link al repositorio: https://github.com/ana/tp3"'),
|
|
308
|
+
enviar: z
|
|
309
|
+
.boolean()
|
|
310
|
+
.optional()
|
|
311
|
+
.describe("Solo en tareas con borradores: confirmar el envío para que cuente como entregada. Por " +
|
|
312
|
+
"defecto, true. Ej: false para dejarla como borrador."),
|
|
313
|
+
aceptoDeclaracion: z
|
|
314
|
+
.boolean()
|
|
315
|
+
.optional()
|
|
316
|
+
.describe("true si el usuario aceptó la `declaracion` de la tarea. Solo hace falta si la tarea la pide. Ej: true."),
|
|
317
|
+
});
|
|
318
|
+
const httpSubmitSchema = submitSchema.extend({
|
|
319
|
+
subida: z
|
|
320
|
+
.string()
|
|
321
|
+
.optional()
|
|
322
|
+
.describe("`subida` de aula_assign_upload_link, ya usado: sus archivos se suman a la entrega. " +
|
|
323
|
+
'Ej: "q3Vx9kP2mW7tR1yZ8nB4cD6fG0hJ5lA".'),
|
|
324
|
+
});
|
|
325
|
+
server.registerTool("aula_assign_submit", {
|
|
326
|
+
description: "Entrega una tarea (assign) del aula virtual: sube archivos, escribe el texto en línea o las " +
|
|
327
|
+
"dos cosas, según lo que acepte la tarea. Antes, abrila con aula_open: `entrega` dice qué " +
|
|
328
|
+
"acepta (`acepta`, con tipos, cantidad y tamaño), si `puedeEditar` y lo que ya está subido.\n\n" +
|
|
329
|
+
newFiles +
|
|
330
|
+
" `archivos` es la lista final y reemplaza lo anterior: para sumar uno sin perder los otros, " +
|
|
331
|
+
"incluí la `url` de cada archivo que ya está en la entrega. Si no pasás `archivos`, quedan " +
|
|
332
|
+
"los que estaban; lo mismo con `texto`.\n\n" +
|
|
333
|
+
"Si la tarea no usa borradores, guardar ya es entregar. Si los usa (`requiereConfirmarEnvio`), " +
|
|
334
|
+
"la tool además confirma el envío, salvo con `enviar: false`; una vez enviada, en general no se " +
|
|
335
|
+
"puede cambiar. Con `declaracion`, el usuario tiene que aceptarla (`aceptoDeclaracion`). " +
|
|
336
|
+
"Devuelve cómo quedó la entrega." +
|
|
337
|
+
CONFIRM_FIRST +
|
|
338
|
+
" Incluí la tarea, los nombres de los archivos y si se confirma el envío.",
|
|
339
|
+
// `subida` existe solo en HTTP; el cast es para que el handler la vea en los dos modos.
|
|
340
|
+
inputSchema: (files.local ? submitSchema : httpSubmitSchema),
|
|
341
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
342
|
+
}, async ({ direccion, archivos, subida, texto, enviar, aceptoDeclaracion }) => {
|
|
343
|
+
try {
|
|
344
|
+
return json(await submitAssignment(session, direccion, {
|
|
345
|
+
files: archivos,
|
|
346
|
+
upload: subida,
|
|
347
|
+
text: texto,
|
|
348
|
+
send: enviar ?? true,
|
|
349
|
+
acceptStatement: aceptoDeclaracion ?? false,
|
|
350
|
+
}, files));
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
return failure(e);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
server.registerTool("aula_calendar_add_event", {
|
|
357
|
+
description: "Agrega un evento personal al calendario del aula virtual, que solo ve el usuario: por ejemplo " +
|
|
358
|
+
"un recordatorio de un parcial o una entrega. El aula avisa según la configuración de " +
|
|
359
|
+
"notificaciones del usuario. La fecha va con zona horaria; en Argentina es -03:00.",
|
|
360
|
+
inputSchema: z.object({
|
|
361
|
+
nombre: z.string().min(1).describe('Título del evento. Ej: "Parcial de Física II".'),
|
|
362
|
+
inicio: z
|
|
363
|
+
.string()
|
|
364
|
+
.describe('Fecha y hora en ISO 8601 con zona horaria. Ej: "2026-10-14T18:00:00-03:00".'),
|
|
365
|
+
duracionMinutos: z
|
|
366
|
+
.number()
|
|
367
|
+
.int()
|
|
368
|
+
.min(0)
|
|
369
|
+
.optional()
|
|
370
|
+
.describe("Duración en minutos. Por defecto, sin duración. Ej: 120."),
|
|
371
|
+
descripcion: z.string().optional().describe('Detalle, en texto plano. Ej: "Aula 312, unidades 1 a 4".'),
|
|
372
|
+
}),
|
|
373
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
374
|
+
}, async ({ nombre, inicio, duracionMinutos, descripcion }) => {
|
|
375
|
+
try {
|
|
376
|
+
return json(await addCalendarEvent(session, {
|
|
377
|
+
name: nombre,
|
|
378
|
+
start: inicio,
|
|
379
|
+
durationMinutes: duracionMinutos,
|
|
380
|
+
description: descripcion,
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
return failure(e);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
109
387
|
}
|
package/build/http.js
CHANGED
|
@@ -1,19 +1,137 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import {
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, OAuthErrorCode, verifyBearerToken, } from "@modelcontextprotocol/server";
|
|
3
4
|
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
|
|
4
5
|
import { logger } from "./shared/logger.js";
|
|
6
|
+
import { MAX_UPLOAD_BYTES, formatSize } from "./shared/uploads.js";
|
|
5
7
|
const asMiddleware = (guard) => (req, res, next) => {
|
|
6
8
|
if (guard(req, res))
|
|
7
9
|
next();
|
|
8
10
|
};
|
|
9
|
-
|
|
11
|
+
/**
|
|
12
|
+
* El SDK rechaza los tokens sin vencimiento; al token fijo del modo local le ponemos uno lejano.
|
|
13
|
+
* Segundos desde epoch: 1/1/3000.
|
|
14
|
+
*/
|
|
15
|
+
const NEVER = 32_503_680_000;
|
|
16
|
+
/** El usuario del store dueño del token, en `extra.userId`. */
|
|
17
|
+
export function userIdOf(authInfo) {
|
|
18
|
+
const userId = authInfo?.extra?.userId;
|
|
19
|
+
if (typeof userId !== "number")
|
|
20
|
+
throw new Error("Request sin usuario autenticado.");
|
|
21
|
+
return userId;
|
|
22
|
+
}
|
|
23
|
+
/** Busca el token en el store: el MCP solo acepta tokens de acceso vigentes. */
|
|
24
|
+
function storeVerifier(store) {
|
|
25
|
+
return {
|
|
26
|
+
async verifyAccessToken(token) {
|
|
27
|
+
const found = await store.findToken(token, "access");
|
|
28
|
+
if (!found)
|
|
29
|
+
throw new OAuthError(OAuthErrorCode.InvalidToken, "Token desconocido, vencido o revocado.");
|
|
30
|
+
return {
|
|
31
|
+
token,
|
|
32
|
+
clientId: found.clientId ?? "desconocido",
|
|
33
|
+
scopes: [],
|
|
34
|
+
expiresAt: found.expiresAt ?? NEVER,
|
|
35
|
+
extra: { userId: found.userId },
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** 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) {
|
|
10
42
|
const handler = createMcpHandler(factory, { onerror: (e) => logger.error(e.message) });
|
|
43
|
+
const verifier = storeVerifier(store);
|
|
44
|
+
const requireToken = async (req, res, next) => {
|
|
45
|
+
try {
|
|
46
|
+
// toNodeHandler le pasa `req.auth` al handler como `authInfo`.
|
|
47
|
+
req.auth = await verifyBearerToken(req.headers.authorization, { verifier });
|
|
48
|
+
next();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
const challenge = bearerAuthChallengeResponse(e);
|
|
52
|
+
res.status(challenge.status);
|
|
53
|
+
challenge.headers.forEach((value, name) => res.setHeader(name, value));
|
|
54
|
+
res.send(await challenge.text());
|
|
55
|
+
}
|
|
56
|
+
};
|
|
11
57
|
const app = express();
|
|
12
58
|
app.use(asMiddleware(localhostHostValidation()));
|
|
13
59
|
app.use(asMiddleware(localhostOriginValidation()));
|
|
14
60
|
// Sin express.json(): el handler lee el body por su cuenta.
|
|
15
|
-
app.all("/mcp", toNodeHandler(handler));
|
|
61
|
+
app.all("/mcp", requireToken, toNodeHandler(handler));
|
|
62
|
+
// 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);
|
|
65
|
+
if (!upload)
|
|
66
|
+
return void res.status(404).type("html").send(page("Link vencido", EXPIRED));
|
|
67
|
+
res.type("html").send(page(upload.title, uploadForm(upload.limits)));
|
|
68
|
+
});
|
|
69
|
+
app.post("/subir/:id", (req, res) => receiveUpload(inbox, req, res));
|
|
16
70
|
app.listen(port, "127.0.0.1", () => {
|
|
17
71
|
logger.info(`Servidor MCP escuchando en http://127.0.0.1:${port}/mcp`);
|
|
18
72
|
});
|
|
19
73
|
}
|
|
74
|
+
/** Recibe el multipart del formulario o de `curl -F archivos=@...`. Al navegador le contesta HTML; al resto, JSON. */
|
|
75
|
+
async function receiveUpload(inbox, req, res) {
|
|
76
|
+
const browser = req.accepts(["json", "html"]) === "html";
|
|
77
|
+
const fail = (status, message) => browser
|
|
78
|
+
? res.status(status).type("html").send(page("No se pudo subir", `<p>${escapeHtml(message)}</p>`))
|
|
79
|
+
: res.status(status).json({ error: message });
|
|
80
|
+
const id = String(req.params.id);
|
|
81
|
+
if (!inbox.get(id))
|
|
82
|
+
return void fail(404, "El link de subida venció o no existe.");
|
|
83
|
+
if (Number(req.headers["content-length"]) > MAX_UPLOAD_BYTES) {
|
|
84
|
+
return void fail(413, `La subida pasa el máximo de ${formatSize(MAX_UPLOAD_BYTES)}.`);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// Request de la API web: parsea multipart sin sumar dependencias.
|
|
88
|
+
const request = new Request(`http://localhost${req.url}`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: req.headers,
|
|
91
|
+
body: Readable.toWeb(req),
|
|
92
|
+
duplex: "half",
|
|
93
|
+
});
|
|
94
|
+
const form = await request.formData();
|
|
95
|
+
const files = await Promise.all(form
|
|
96
|
+
.getAll("archivos")
|
|
97
|
+
.filter((v) => typeof v !== "string" && v.size > 0)
|
|
98
|
+
.map(async (f) => ({ name: f.name, bytes: new Uint8Array(await f.arrayBuffer()) })));
|
|
99
|
+
inbox.receive(id, files);
|
|
100
|
+
const received = files.map((f) => ({ nombre: f.name, tamanio: f.bytes.length }));
|
|
101
|
+
if (browser) {
|
|
102
|
+
const list = received.map((f) => `<li>${escapeHtml(f.nombre)} (${formatSize(f.tamanio)})</li>`).join("");
|
|
103
|
+
res.type("html").send(page("Listo", `<ul>${list}</ul><p>Volvé al chat y avisá que ya los subiste.</p>`));
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
res.json({ recibidos: received });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
fail(400, e.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const EXPIRED = "<p>Este link de subida venció o ya no existe. Pedile al agente uno nuevo.</p>";
|
|
114
|
+
function uploadForm(limits) {
|
|
115
|
+
const rules = [
|
|
116
|
+
limits.maxFiles ? `hasta ${limits.maxFiles} archivo(s)` : "",
|
|
117
|
+
`hasta ${formatSize(limits.maxBytes)} cada uno`,
|
|
118
|
+
limits.extensions?.length ? `tipos: ${limits.extensions.join(", ")}` : "",
|
|
119
|
+
].filter(Boolean);
|
|
120
|
+
const accept = limits.extensions?.length ? ` accept="${escapeHtml(limits.extensions.join(","))}"` : "";
|
|
121
|
+
return (`<p>Elegí los archivos para la entrega (${escapeHtml(rules.join(", "))}).</p>` +
|
|
122
|
+
`<form method="post" enctype="multipart/form-data">` +
|
|
123
|
+
`<input type="file" name="archivos" multiple required${accept}>` +
|
|
124
|
+
`<button type="submit">Subir</button></form>` +
|
|
125
|
+
"<p class=nota>Subirlos no entrega la tarea: después el agente te confirma y la entrega.</p>");
|
|
126
|
+
}
|
|
127
|
+
/** Página mínima: se abre desde el celu, así que nada más que lo necesario. */
|
|
128
|
+
function page(title, body) {
|
|
129
|
+
return (`<!doctype html><html lang="es"><head><meta charset="utf-8">` +
|
|
130
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>` +
|
|
131
|
+
"<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:2rem auto;padding:0 1rem;" +
|
|
132
|
+
"line-height:1.5;color:#1a1a1a;background:#fff}@media(prefers-color-scheme:dark){body{color:#eee;" +
|
|
133
|
+
"background:#151515}}form{display:flex;flex-direction:column;gap:1rem;margin:1.5rem 0}" +
|
|
134
|
+
"button{padding:.7rem;font-size:1rem}.nota{opacity:.7;font-size:.9rem}</style></head>" +
|
|
135
|
+
`<body><h1>${escapeHtml(title)}</h1>${body}</body></html>`);
|
|
136
|
+
}
|
|
137
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
package/build/index.js
CHANGED
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
4
|
import { LocalSession } from "./aula-virtual/client/local-session.js";
|
|
5
|
+
import { StoredAulaSession } from "./aula-virtual/client/stored-session.js";
|
|
5
6
|
import { runLogin } from "./aula-virtual/login.js";
|
|
6
|
-
import { startHttpServer } from "./http.js";
|
|
7
|
+
import { startHttpServer, userIdOf } from "./http.js";
|
|
7
8
|
import { createServer } from "./server.js";
|
|
8
9
|
import { logger } from "./shared/logger.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
utn-mcp
|
|
10
|
+
import { SqliteStore } from "./shared/store/sqlite-store.js";
|
|
11
|
+
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`;
|
|
13
17
|
async function main() {
|
|
14
18
|
const { values, positionals } = parseArgs({
|
|
15
19
|
allowPositionals: true,
|
|
@@ -24,17 +28,25 @@ async function main() {
|
|
|
24
28
|
return;
|
|
25
29
|
}
|
|
26
30
|
if (positionals[0] === "login") {
|
|
27
|
-
await runLogin();
|
|
31
|
+
await runLogin(values.http);
|
|
28
32
|
return;
|
|
29
33
|
}
|
|
30
|
-
// Modo local: un solo usuario, la misma sesión para todas las conexiones.
|
|
31
|
-
const aula = new LocalSession();
|
|
32
|
-
const factory = () => createServer(aula);
|
|
33
34
|
if (values.http) {
|
|
34
|
-
|
|
35
|
+
// Cada request trae el token de un usuario del store y se arma con su sesión. Los archivos
|
|
36
|
+
// llegan por links de subida: el servidor puede no estar en la compu de quien entrega.
|
|
37
|
+
const port = Number(values.port);
|
|
38
|
+
const store = await SqliteStore.open();
|
|
39
|
+
const inbox = new UploadInbox(process.env.UTN_MCP_PUBLIC_URL ?? `http://127.0.0.1:${port}`);
|
|
40
|
+
const factory = (ctx) => {
|
|
41
|
+
const userId = userIdOf(ctx.authInfo);
|
|
42
|
+
return createServer(new StoredAulaSession(store, userId), { local: false, inbox, owner: String(userId) });
|
|
43
|
+
};
|
|
44
|
+
startHttpServer(factory, port, store, inbox);
|
|
35
45
|
}
|
|
36
46
|
else {
|
|
37
|
-
|
|
47
|
+
// STDIO: un solo usuario, el de la compu, con la misma sesión para todas las conexiones.
|
|
48
|
+
const aula = new LocalSession();
|
|
49
|
+
serveStdio(() => createServer(aula, { local: true }), { onerror: (e) => logger.error(e.message) });
|
|
38
50
|
logger.info("Servidor MCP iniciado en STDIO");
|
|
39
51
|
}
|
|
40
52
|
}
|
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.6.9",
|
|
8
11
|
});
|
|
9
|
-
registerAulaVirtualTools(server, aula);
|
|
12
|
+
registerAulaVirtualTools(server, aula, files);
|
|
10
13
|
return server;
|
|
11
14
|
}
|