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 +8 -0
- package/CHANGELOG.md +12 -0
- package/README.md +11 -9
- package/package.json +1 -1
- package/src/mcp/server.ts +159 -51
package/.mcp.json
ADDED
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/
|
|
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:
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
71
|
+
blackboard assignments list _100004_1
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
Nota: sin entregar · Máx:
|
|
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
package/src/mcp/server.ts
CHANGED
|
@@ -30,21 +30,21 @@ export async function startMcpServer() {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
// ── whoami ─────────────────────────────────────────────────────────────────
|
|
33
|
-
server.
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
+
server.registerTool(
|
|
57
57
|
'get_course',
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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
|
-
|
|
73
|
-
|
|
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.
|
|
87
|
+
server.registerTool(
|
|
84
88
|
'list_announcements',
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
101
|
+
server.registerTool(
|
|
96
102
|
'list_assignments',
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
115
|
+
server.registerTool(
|
|
108
116
|
'list_attempts',
|
|
109
|
-
'List submission attempts for a specific assignment (gradebook column)',
|
|
110
117
|
{
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
132
|
+
server.registerTool(
|
|
123
133
|
'get_grades',
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
156
|
+
server.registerTool(
|
|
145
157
|
'download_attachment',
|
|
146
|
-
'Download a file from a course content item. Returns base64-encoded content.',
|
|
147
158
|
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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.
|
|
191
|
+
server.registerTool(
|
|
165
192
|
'list_attachments',
|
|
166
|
-
'List file attachments for a course content item',
|
|
167
193
|
{
|
|
168
|
-
|
|
169
|
-
|
|
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}
|
|
215
|
+
`/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
|
|
175
216
|
);
|
|
176
|
-
|
|
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(/"/g, '"'));
|
|
228
|
+
const downloadUrl = hrefRaw ? hrefRaw.replace(/&/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.
|
|
285
|
+
server.registerTool(
|
|
182
286
|
'submit_attempt',
|
|
183
|
-
'Submit an assignment attempt. ALWAYS confirm with the user before submitting.',
|
|
184
287
|
{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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.
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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();
|