blackboard-upc 1.0.1 → 1.0.3

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/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "blackboard": {
4
+ "command": "npx",
5
+ "args": ["blackboard-upc", "mcp"]
6
+ }
7
+ }
8
+ }
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `blackboard-upc` will be documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.0.2] — 2026-03-30
8
+
9
+ ### Fixed
10
+ - `list_attachments` — fallback automático a parseo del HTML del `body` para contenido tipo `x-bb-document` y `x-bb-lesson` (antes retornaba 400 en estos casos)
11
+ - `download_attachment` — ahora acepta URLs directas de `bbcswebdav` además de IDs estándar de Blackboard
12
+
13
+ ### Added
14
+ - `download_file_url` (MCP) — nueva herramienta para descargar archivos embebidos directamente desde URLs de `bbcswebdav` con las cookies de sesión autenticadas
15
+ - Todos los tools de descarga ahora retornan `filename`, `mimeType` y `size` junto al contenido `base64`
16
+
17
+ ---
18
+
7
19
  ## [1.0.1] — 2026-03-30
8
20
 
9
21
  ### Added
package/README.md CHANGED
@@ -21,7 +21,7 @@ npm install -g blackboard-upc
21
21
  blackboard login
22
22
 
23
23
  # Opción 3 — clonar el repo
24
- git clone https://github.com/aleoroni/blackboard-cli
24
+ git clone https://github.com/alejooroncoy/blackboard-cli
25
25
  cd blackboard-cli
26
26
  npm install
27
27
  npx playwright install chromium
@@ -40,13 +40,15 @@ blackboard login
40
40
 
41
41
  Se abre una ventana del navegador con el login de Microsoft UPC. Inicia sesión con tu cuenta `u20XXXXXXX@upc.edu.pe` (incluye MFA si lo tienes). La ventana se cierra sola y la sesión queda guardada 8 horas.
42
42
 
43
+ > **Importante:** durante el login, Microsoft mostrará el mensaje **"Stay signed in?"** con un checkbox **"Don't show this again"**. Activa ese checkbox y haz clic en **Yes** — esto le indica a Microsoft que mantenga la sesión activa y es necesario para que el CLI pueda guardar las cookies correctamente.
44
+
43
45
  ```
44
46
  ██████ ██ █████ ██████ ██ ██ ██████ ██████ █████ ██████ ██████
45
47
  ...
46
48
  CLI no oficial para UPC Aula Virtual · Blackboard Learn
47
49
 
48
50
  ✓ Sesión guardada — expira en 8 horas
49
- Usuario: Alejandro Daniel Oroncoy Almeyda
51
+ Usuario: Juan Pérez García
50
52
 
51
53
  ¿Qué puedo hacer ahora?
52
54
 
@@ -61,15 +63,15 @@ Se abre una ventana del navegador con el login de Microsoft UPC. Inicia sesión
61
63
  ```bash
62
64
  blackboard courses list
63
65
 
64
- _529630_1 Fundamentos de Arquitectura de Software [Ultra]
65
- _529580_1 Finanzas e Ingeniería Económica [Ultra]
66
- _529533_1 Desarrollo de Soluciones IOT [Ultra]
67
- _529760_1 Diseño de Experimentos de Ingeniería de Software [Ultra]
66
+ _100001_1 Cálculo Diferencial e Integral [Ultra]
67
+ _100002_1 Programación Orientada a Objetos [Ultra]
68
+ _100003_1 Bases de Datos [Ultra]
69
+ _100004_1 Algoritmos y Estructuras de Datos [Ultra]
68
70
 
69
- blackboard assignments list _529760_1
71
+ blackboard assignments list _100004_1
70
72
 
71
- _13890556_1 Tarea 1 [manual]
72
- Nota: sin entregar · Máx: 3 pts · Entrega: 05/03/2026 (vencida hace 23d)
73
+ _200001_1 Tarea 1 [manual]
74
+ Nota: sin entregar · Máx: 5 pts · Entrega: 15/04/2026 (vence en 17d)
73
75
  ```
74
76
 
75
77
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blackboard-upc",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "CLI no oficial para UPC Aula Virtual (Blackboard Learn) — acceso desde la terminal y MCP para Claude",
5
5
  "main": "run.js",
6
6
  "bin": {
package/src/mcp/server.ts CHANGED
@@ -30,21 +30,21 @@ export async function startMcpServer() {
30
30
  });
31
31
 
32
32
  // ── whoami ─────────────────────────────────────────────────────────────────
33
- server.tool('whoami', 'Get the currently authenticated UPC student info', {}, async () => {
33
+ server.registerTool('whoami', { description: 'Get the currently authenticated UPC student info' }, async () => {
34
34
  const { client } = getClient();
35
35
  const me = await getMe(client);
36
36
  return { content: [{ type: 'text', text: JSON.stringify(me, null, 2) }] };
37
37
  });
38
38
 
39
39
  // ── system_version ─────────────────────────────────────────────────────────
40
- server.tool('system_version', 'Get Blackboard Learn server version', {}, async () => {
40
+ server.registerTool('system_version', { description: 'Get Blackboard Learn server version' }, async () => {
41
41
  const { client } = getClient();
42
42
  const v = await getSystemVersion(client);
43
43
  return { content: [{ type: 'text', text: JSON.stringify(v, null, 2) }] };
44
44
  });
45
45
 
46
46
  // ── list_courses ────────────────────────────────────────────────────────────
47
- server.tool('list_courses', 'List all enrolled courses for the current student', {}, async () => {
47
+ server.registerTool('list_courses', { description: 'List all enrolled courses for the current student' }, async () => {
48
48
  const { client, session } = getClient();
49
49
  let userId = session.userId;
50
50
  if (!userId) { const me = await getMe(client); userId = me.id; }
@@ -53,10 +53,12 @@ export async function startMcpServer() {
53
53
  });
54
54
 
55
55
  // ── get_course ──────────────────────────────────────────────────────────────
56
- server.tool(
56
+ server.registerTool(
57
57
  'get_course',
58
- 'Get details of a specific course by its Blackboard ID (e.g. _529580_1)',
59
- { courseId: z.string().describe('Blackboard course ID like _529580_1') },
58
+ {
59
+ description: 'Get details of a specific course by its Blackboard ID (e.g. _529580_1)',
60
+ inputSchema: { courseId: z.string().describe('Blackboard course ID like _529580_1') },
61
+ },
60
62
  async ({ courseId }) => {
61
63
  const { client } = getClient();
62
64
  const data = await getCourse(client, courseId);
@@ -65,12 +67,14 @@ export async function startMcpServer() {
65
67
  );
66
68
 
67
69
  // ── list_contents ───────────────────────────────────────────────────────────
68
- server.tool(
70
+ server.registerTool(
69
71
  'list_contents',
70
- 'List content items inside a course or folder. Use parentId to navigate into subfolders.',
71
72
  {
72
- courseId: z.string().describe('Blackboard course ID'),
73
- parentId: z.string().optional().describe('Parent folder content ID (omit for root level)'),
73
+ description: 'List content items inside a course or folder. Use parentId to navigate into subfolders.',
74
+ inputSchema: {
75
+ courseId: z.string().describe('Blackboard course ID'),
76
+ parentId: z.string().optional().describe('Parent folder content ID (omit for root level)'),
77
+ },
74
78
  },
75
79
  async ({ courseId, parentId }) => {
76
80
  const { client } = getClient();
@@ -80,10 +84,12 @@ export async function startMcpServer() {
80
84
  );
81
85
 
82
86
  // ── list_announcements ──────────────────────────────────────────────────────
83
- server.tool(
87
+ server.registerTool(
84
88
  'list_announcements',
85
- 'List recent announcements for a course',
86
- { courseId: z.string().describe('Blackboard course ID') },
89
+ {
90
+ description: 'List recent announcements for a course',
91
+ inputSchema: { courseId: z.string().describe('Blackboard course ID') },
92
+ },
87
93
  async ({ courseId }) => {
88
94
  const { client } = getClient();
89
95
  const data = await getCourseAnnouncements(client, courseId);
@@ -92,10 +98,12 @@ export async function startMcpServer() {
92
98
  );
93
99
 
94
100
  // ── list_assignments ────────────────────────────────────────────────────────
95
- server.tool(
101
+ server.registerTool(
96
102
  'list_assignments',
97
- 'List assignments and tasks in a course with due dates, scores and submission status',
98
- { courseId: z.string().describe('Blackboard course ID') },
103
+ {
104
+ description: 'List assignments and tasks in a course with due dates, scores and submission status',
105
+ inputSchema: { courseId: z.string().describe('Blackboard course ID') },
106
+ },
99
107
  async ({ courseId }) => {
100
108
  const { client } = getClient();
101
109
  const data = await listAssignments(client, courseId);
@@ -104,12 +112,14 @@ export async function startMcpServer() {
104
112
  );
105
113
 
106
114
  // ── list_attempts ───────────────────────────────────────────────────────────
107
- server.tool(
115
+ server.registerTool(
108
116
  'list_attempts',
109
- 'List submission attempts for a specific assignment (gradebook column)',
110
117
  {
111
- courseId: z.string().describe('Blackboard course ID'),
112
- columnId: z.string().describe('Gradebook column ID (assignment ID)'),
118
+ description: 'List submission attempts for a specific assignment (gradebook column)',
119
+ inputSchema: {
120
+ courseId: z.string().describe('Blackboard course ID'),
121
+ columnId: z.string().describe('Gradebook column ID (assignment ID)'),
122
+ },
113
123
  },
114
124
  async ({ courseId, columnId }) => {
115
125
  const { client } = getClient();
@@ -119,10 +129,12 @@ export async function startMcpServer() {
119
129
  );
120
130
 
121
131
  // ── get_grades ──────────────────────────────────────────────────────────────
122
- server.tool(
132
+ server.registerTool(
123
133
  'get_grades',
124
- 'Get all grades for the current student in a course',
125
- { courseId: z.string().describe('Blackboard course ID') },
134
+ {
135
+ description: 'Get all grades for the current student in a course',
136
+ inputSchema: { courseId: z.string().describe('Blackboard course ID') },
137
+ },
126
138
  async ({ courseId }) => {
127
139
  const { client, session } = getClient();
128
140
  let userId = session.userId;
@@ -141,51 +153,145 @@ export async function startMcpServer() {
141
153
  );
142
154
 
143
155
  // ── download_attachment ─────────────────────────────────────────────────────
144
- server.tool(
156
+ server.registerTool(
145
157
  'download_attachment',
146
- 'Download a file from a course content item. Returns base64-encoded content.',
147
158
  {
148
- courseId: z.string().describe('Blackboard course ID'),
149
- contentId: z.string().describe('Content item ID'),
150
- attachmentId: z.string().describe('Attachment ID from list_attachments'),
159
+ description: 'Download a file from a course content item. attachmentId can be a Blackboard attachment ID (for x-bb-file) or a full bbcswebdav URL (for x-bb-document embedded files). Returns base64-encoded content.',
160
+ inputSchema: {
161
+ courseId: z.string().describe('Blackboard course ID'),
162
+ contentId: z.string().describe('Content item ID'),
163
+ attachmentId: z.string().describe('Attachment ID from list_attachments, or a full bbcswebdav URL for embedded files'),
164
+ },
151
165
  },
152
166
  async ({ courseId, contentId, attachmentId }) => {
153
167
  const { client } = getClient();
154
- const r = await client.get(
155
- `/learn/api/public/v1/courses/${courseId}/contents/${contentId}/attachments/${attachmentId}/download`,
156
- { responseType: 'arraybuffer' }
157
- );
168
+
169
+ // If attachmentId is a full URL (embedded file), download directly
170
+ const url = attachmentId.startsWith('http')
171
+ ? attachmentId
172
+ : `/learn/api/public/v1/courses/${courseId}/contents/${contentId}/attachments/${attachmentId}/download`;
173
+
174
+ const r = await client.get(url, { responseType: 'arraybuffer', headers: { Accept: '*/*' } });
158
175
  const b64 = Buffer.from(r.data).toString('base64');
159
- return { content: [{ type: 'text', text: b64 }] };
176
+ const contentDisposition = r.headers['content-disposition'] as string | undefined;
177
+ const filename = contentDisposition
178
+ ? (contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/))?.[1]?.replace(/['"]/g, '').trim()
179
+ : undefined;
180
+ const mimeType = (r.headers['content-type'] as string | undefined) ?? 'application/octet-stream';
181
+ return {
182
+ content: [{
183
+ type: 'text',
184
+ text: JSON.stringify({ filename: filename ?? 'file', mimeType, size: r.data.byteLength, base64: b64 }, null, 2),
185
+ }],
186
+ };
160
187
  }
161
188
  );
162
189
 
163
190
  // ── list_attachments ────────────────────────────────────────────────────────
164
- server.tool(
191
+ server.registerTool(
165
192
  'list_attachments',
166
- 'List file attachments for a course content item',
167
193
  {
168
- courseId: z.string().describe('Blackboard course ID'),
169
- contentId: z.string().describe('Content item ID'),
194
+ description: 'List file attachments for a course content item. Works for x-bb-file (REST API) and x-bb-document (embedded files in body HTML).',
195
+ inputSchema: {
196
+ courseId: z.string().describe('Blackboard course ID'),
197
+ contentId: z.string().describe('Content item ID'),
198
+ },
170
199
  },
171
200
  async ({ courseId, contentId }) => {
172
201
  const { client } = getClient();
202
+
203
+ // Try standard REST attachments endpoint first (works for x-bb-file)
204
+ try {
205
+ const r = await client.get(
206
+ `/learn/api/public/v1/courses/${courseId}/contents/${contentId}/attachments`
207
+ );
208
+ return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }] };
209
+ } catch (err: any) {
210
+ if (err.response?.status !== 400 && err.response?.status !== 404) throw err;
211
+ }
212
+
213
+ // Fallback: fetch content and parse embedded files from body HTML (x-bb-document, x-bb-lesson)
173
214
  const r = await client.get(
174
- `/learn/api/public/v1/courses/${courseId}/contents/${contentId}/attachments`
215
+ `/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
175
216
  );
176
- return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }] };
217
+ const body: string = r.data?.body ?? '';
218
+
219
+ // Extract <a> tags with data-bbfile — capture both the JSON metadata and the href (signed download URL)
220
+ // Handle both attribute orderings: data-bbfile...href and href...data-bbfile
221
+ const filePattern = /data-bbfile="([^"]+)"[^<]*?href="([^"]+)"|href="([^"]+)"[^<]*?data-bbfile="([^"]+)"/g;
222
+ const anchorMatches = [...body.matchAll(filePattern)];
223
+ const files = anchorMatches.map((m) => {
224
+ const bbfileRaw = m[1] ?? m[4];
225
+ const hrefRaw = m[2] ?? m[3];
226
+ try {
227
+ const meta = JSON.parse(bbfileRaw.replace(/&quot;/g, '"'));
228
+ const downloadUrl = hrefRaw ? hrefRaw.replace(/&amp;/g, '&') : (meta.resourceUrl ?? null);
229
+ return {
230
+ type: 'embedded',
231
+ displayName: meta.displayName ?? meta.linkName ?? 'unknown',
232
+ mimeType: meta.mimeType ?? 'application/octet-stream',
233
+ downloadUrl,
234
+ };
235
+ } catch {
236
+ return null;
237
+ }
238
+ }).filter(Boolean);
239
+
240
+ return {
241
+ content: [{
242
+ type: 'text',
243
+ text: JSON.stringify(
244
+ { type: 'embedded_files', note: 'Pass downloadUrl as attachmentId to download_attachment', results: files },
245
+ null, 2
246
+ ),
247
+ }],
248
+ };
249
+ }
250
+ );
251
+
252
+ // ── download_file_url ───────────────────────────────────────────────────────
253
+ server.registerTool(
254
+ 'download_file_url',
255
+ {
256
+ description: 'Download a file directly from a Blackboard bbcswebdav URL (for x-bb-document embedded files). Returns base64-encoded content and filename.',
257
+ inputSchema: {
258
+ url: z.string().describe('Direct file URL from bbcswebdav (downloadUrl from list_attachments)'),
259
+ filename: z.string().optional().describe('Desired filename for saving the file'),
260
+ },
261
+ },
262
+ async ({ url, filename }) => {
263
+ const { client } = getClient();
264
+ const r = await client.get(url, {
265
+ responseType: 'arraybuffer',
266
+ headers: { Accept: '*/*' },
267
+ });
268
+ const b64 = Buffer.from(r.data).toString('base64');
269
+ const contentDisposition = r.headers['content-disposition'] as string | undefined;
270
+ const detectedName = contentDisposition
271
+ ? (contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/))?.[1]?.replace(/['"]/g, '').trim()
272
+ : undefined;
273
+ const finalName = filename ?? detectedName ?? 'file';
274
+ const mimeType = (r.headers['content-type'] as string | undefined) ?? 'application/octet-stream';
275
+ return {
276
+ content: [{
277
+ type: 'text',
278
+ text: JSON.stringify({ filename: finalName, mimeType, size: r.data.byteLength, base64: b64 }, null, 2),
279
+ }],
280
+ };
177
281
  }
178
282
  );
179
283
 
180
284
  // ── submit_attempt ──────────────────────────────────────────────────────────
181
- server.tool(
285
+ server.registerTool(
182
286
  'submit_attempt',
183
- 'Submit an assignment attempt. ALWAYS confirm with the user before submitting.',
184
287
  {
185
- courseId: z.string().describe('Blackboard course ID'),
186
- columnId: z.string().describe('Assignment (gradebook column) ID'),
187
- studentComments: z.string().optional().describe('Comment to the instructor'),
188
- studentSubmission: z.string().optional().describe('Text body of the submission'),
288
+ description: 'Submit an assignment attempt. ALWAYS confirm with the user before submitting.',
289
+ inputSchema: {
290
+ courseId: z.string().describe('Blackboard course ID'),
291
+ columnId: z.string().describe('Assignment (gradebook column) ID'),
292
+ studentComments: z.string().optional().describe('Comment to the instructor'),
293
+ studentSubmission: z.string().optional().describe('Text body of the submission'),
294
+ },
189
295
  },
190
296
  async ({ courseId, columnId, studentComments, studentSubmission }) => {
191
297
  const { client } = getClient();
@@ -199,14 +305,16 @@ export async function startMcpServer() {
199
305
  );
200
306
 
201
307
  // ── raw_api ─────────────────────────────────────────────────────────────────
202
- server.tool(
308
+ server.registerTool(
203
309
  'raw_api',
204
- 'Make a raw REST API call to Blackboard Learn. Use for any endpoint not covered by other tools.',
205
310
  {
206
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
207
- path: z.string().describe('API path, e.g. /learn/api/public/v1/users/me'),
208
- query: z.string().optional().describe('Query string, e.g. limit=10&offset=0'),
209
- body: z.string().optional().describe('JSON body string for POST/PUT/PATCH'),
311
+ description: 'Make a raw REST API call to Blackboard Learn. Use for any endpoint not covered by other tools.',
312
+ inputSchema: {
313
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
314
+ path: z.string().describe('API path, e.g. /learn/api/public/v1/users/me'),
315
+ query: z.string().optional().describe('Query string, e.g. limit=10&offset=0'),
316
+ body: z.string().optional().describe('JSON body string for POST/PUT/PATCH'),
317
+ },
210
318
  },
211
319
  async ({ method, path, query, body }) => {
212
320
  const { client } = getClient();