blackboard-upc 1.0.2 → 1.0.4
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/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/src/mcp/server.ts +109 -66
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to `blackboard-upc` will be documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.0.3] — 2026-03-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Todos los tools MCP migrados de `server.tool()` a `server.registerTool()` (API nueva del SDK v1.28+)
|
|
11
|
+
- Elimina todos los warnings de TypeScript por uso de API deprecada
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [1.0.2] — 2026-03-30
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- `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)
|
|
19
|
+
- `download_attachment` — ahora acepta URLs directas de `bbcswebdav` además de IDs estándar de Blackboard
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `download_file_url` (MCP) — nueva herramienta para descargar archivos embebidos directamente desde URLs de `bbcswebdav` con las cookies de sesión autenticadas
|
|
23
|
+
- Todos los tools de descarga ahora retornan `filename`, `mimeType` y `size` junto al contenido `base64`
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
7
27
|
## [1.0.1] — 2026-03-30
|
|
8
28
|
|
|
9
29
|
### Added
|
package/package.json
CHANGED
package/src/mcp/server.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
4
6
|
import { loadSession, isSessionValid } from '../auth/session.js';
|
|
5
7
|
import { createClient } from '../api/client.js';
|
|
6
8
|
import {
|
|
@@ -30,21 +32,21 @@ export async function startMcpServer() {
|
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
// ── whoami ─────────────────────────────────────────────────────────────────
|
|
33
|
-
server.
|
|
35
|
+
server.registerTool('whoami', { description: 'Get the currently authenticated UPC student info' }, async () => {
|
|
34
36
|
const { client } = getClient();
|
|
35
37
|
const me = await getMe(client);
|
|
36
38
|
return { content: [{ type: 'text', text: JSON.stringify(me, null, 2) }] };
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
// ── system_version ─────────────────────────────────────────────────────────
|
|
40
|
-
server.
|
|
42
|
+
server.registerTool('system_version', { description: 'Get Blackboard Learn server version' }, async () => {
|
|
41
43
|
const { client } = getClient();
|
|
42
44
|
const v = await getSystemVersion(client);
|
|
43
45
|
return { content: [{ type: 'text', text: JSON.stringify(v, null, 2) }] };
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
// ── list_courses ────────────────────────────────────────────────────────────
|
|
47
|
-
server.
|
|
49
|
+
server.registerTool('list_courses', { description: 'List all enrolled courses for the current student' }, async () => {
|
|
48
50
|
const { client, session } = getClient();
|
|
49
51
|
let userId = session.userId;
|
|
50
52
|
if (!userId) { const me = await getMe(client); userId = me.id; }
|
|
@@ -53,10 +55,12 @@ export async function startMcpServer() {
|
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
// ── get_course ──────────────────────────────────────────────────────────────
|
|
56
|
-
server.
|
|
58
|
+
server.registerTool(
|
|
57
59
|
'get_course',
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
{
|
|
61
|
+
description: 'Get details of a specific course by its Blackboard ID (e.g. _529580_1)',
|
|
62
|
+
inputSchema: { courseId: z.string().describe('Blackboard course ID like _529580_1') },
|
|
63
|
+
},
|
|
60
64
|
async ({ courseId }) => {
|
|
61
65
|
const { client } = getClient();
|
|
62
66
|
const data = await getCourse(client, courseId);
|
|
@@ -65,12 +69,14 @@ export async function startMcpServer() {
|
|
|
65
69
|
);
|
|
66
70
|
|
|
67
71
|
// ── list_contents ───────────────────────────────────────────────────────────
|
|
68
|
-
server.
|
|
72
|
+
server.registerTool(
|
|
69
73
|
'list_contents',
|
|
70
|
-
'List content items inside a course or folder. Use parentId to navigate into subfolders.',
|
|
71
74
|
{
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
description: 'List content items inside a course or folder. Use parentId to navigate into subfolders.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
courseId: z.string().describe('Blackboard course ID'),
|
|
78
|
+
parentId: z.string().optional().describe('Parent folder content ID (omit for root level)'),
|
|
79
|
+
},
|
|
74
80
|
},
|
|
75
81
|
async ({ courseId, parentId }) => {
|
|
76
82
|
const { client } = getClient();
|
|
@@ -80,10 +86,12 @@ export async function startMcpServer() {
|
|
|
80
86
|
);
|
|
81
87
|
|
|
82
88
|
// ── list_announcements ──────────────────────────────────────────────────────
|
|
83
|
-
server.
|
|
89
|
+
server.registerTool(
|
|
84
90
|
'list_announcements',
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
{
|
|
92
|
+
description: 'List recent announcements for a course',
|
|
93
|
+
inputSchema: { courseId: z.string().describe('Blackboard course ID') },
|
|
94
|
+
},
|
|
87
95
|
async ({ courseId }) => {
|
|
88
96
|
const { client } = getClient();
|
|
89
97
|
const data = await getCourseAnnouncements(client, courseId);
|
|
@@ -92,10 +100,12 @@ export async function startMcpServer() {
|
|
|
92
100
|
);
|
|
93
101
|
|
|
94
102
|
// ── list_assignments ────────────────────────────────────────────────────────
|
|
95
|
-
server.
|
|
103
|
+
server.registerTool(
|
|
96
104
|
'list_assignments',
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
{
|
|
106
|
+
description: 'List assignments and tasks in a course with due dates, scores and submission status',
|
|
107
|
+
inputSchema: { courseId: z.string().describe('Blackboard course ID') },
|
|
108
|
+
},
|
|
99
109
|
async ({ courseId }) => {
|
|
100
110
|
const { client } = getClient();
|
|
101
111
|
const data = await listAssignments(client, courseId);
|
|
@@ -104,12 +114,14 @@ export async function startMcpServer() {
|
|
|
104
114
|
);
|
|
105
115
|
|
|
106
116
|
// ── list_attempts ───────────────────────────────────────────────────────────
|
|
107
|
-
server.
|
|
117
|
+
server.registerTool(
|
|
108
118
|
'list_attempts',
|
|
109
|
-
'List submission attempts for a specific assignment (gradebook column)',
|
|
110
119
|
{
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
description: 'List submission attempts for a specific assignment (gradebook column)',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
courseId: z.string().describe('Blackboard course ID'),
|
|
123
|
+
columnId: z.string().describe('Gradebook column ID (assignment ID)'),
|
|
124
|
+
},
|
|
113
125
|
},
|
|
114
126
|
async ({ courseId, columnId }) => {
|
|
115
127
|
const { client } = getClient();
|
|
@@ -119,10 +131,12 @@ export async function startMcpServer() {
|
|
|
119
131
|
);
|
|
120
132
|
|
|
121
133
|
// ── get_grades ──────────────────────────────────────────────────────────────
|
|
122
|
-
server.
|
|
134
|
+
server.registerTool(
|
|
123
135
|
'get_grades',
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
{
|
|
137
|
+
description: 'Get all grades for the current student in a course',
|
|
138
|
+
inputSchema: { courseId: z.string().describe('Blackboard course ID') },
|
|
139
|
+
},
|
|
126
140
|
async ({ courseId }) => {
|
|
127
141
|
const { client, session } = getClient();
|
|
128
142
|
let userId = session.userId;
|
|
@@ -141,45 +155,57 @@ export async function startMcpServer() {
|
|
|
141
155
|
);
|
|
142
156
|
|
|
143
157
|
// ── download_attachment ─────────────────────────────────────────────────────
|
|
144
|
-
server.
|
|
158
|
+
server.registerTool(
|
|
145
159
|
'download_attachment',
|
|
146
|
-
'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.',
|
|
147
160
|
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
description: 'Download a file from a course content item and save it to disk. attachmentId can be a Blackboard attachment ID (for x-bb-file) or a full bbcswebdav URL (for x-bb-document embedded files). Saves to outputDir (default: current working directory).',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
courseId: z.string().describe('Blackboard course ID'),
|
|
164
|
+
contentId: z.string().describe('Content item ID'),
|
|
165
|
+
attachmentId: z.string().describe('Attachment ID from list_attachments, or a full bbcswebdav URL for embedded files'),
|
|
166
|
+
filename: z.string().optional().describe('Filename to save as (e.g. displayName from list_attachments). Falls back to Content-Disposition header.'),
|
|
167
|
+
outputDir: z.string().optional().describe('Directory to save the file (default: current working directory)'),
|
|
168
|
+
},
|
|
151
169
|
},
|
|
152
|
-
async ({ courseId, contentId, attachmentId }) => {
|
|
170
|
+
async ({ courseId, contentId, attachmentId, filename, outputDir }) => {
|
|
153
171
|
const { client } = getClient();
|
|
154
172
|
|
|
155
|
-
// If attachmentId is a full URL (embedded file), download directly
|
|
156
173
|
const url = attachmentId.startsWith('http')
|
|
157
174
|
? attachmentId
|
|
158
175
|
: `/learn/api/public/v1/courses/${courseId}/contents/${contentId}/attachments/${attachmentId}/download`;
|
|
159
176
|
|
|
160
177
|
const r = await client.get(url, { responseType: 'arraybuffer', headers: { Accept: '*/*' } });
|
|
161
|
-
|
|
178
|
+
|
|
162
179
|
const contentDisposition = r.headers['content-disposition'] as string | undefined;
|
|
163
|
-
const
|
|
180
|
+
const detectedName = contentDisposition
|
|
164
181
|
? (contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/))?.[1]?.replace(/['"]/g, '').trim()
|
|
165
182
|
: undefined;
|
|
183
|
+
const finalName = filename ?? detectedName ?? 'download';
|
|
184
|
+
|
|
185
|
+
const dir = path.resolve(outputDir ?? process.cwd());
|
|
186
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
187
|
+
const dest = path.join(dir, finalName);
|
|
188
|
+
fs.writeFileSync(dest, Buffer.from(r.data));
|
|
189
|
+
|
|
166
190
|
const mimeType = (r.headers['content-type'] as string | undefined) ?? 'application/octet-stream';
|
|
167
191
|
return {
|
|
168
192
|
content: [{
|
|
169
193
|
type: 'text',
|
|
170
|
-
text: JSON.stringify({
|
|
194
|
+
text: JSON.stringify({ saved: dest, size: r.data.byteLength, mimeType }, null, 2),
|
|
171
195
|
}],
|
|
172
196
|
};
|
|
173
197
|
}
|
|
174
198
|
);
|
|
175
199
|
|
|
176
200
|
// ── list_attachments ────────────────────────────────────────────────────────
|
|
177
|
-
server.
|
|
201
|
+
server.registerTool(
|
|
178
202
|
'list_attachments',
|
|
179
|
-
'List file attachments for a course content item. Works for x-bb-file (REST API) and x-bb-document (embedded files in body HTML).',
|
|
180
203
|
{
|
|
181
|
-
|
|
182
|
-
|
|
204
|
+
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).',
|
|
205
|
+
inputSchema: {
|
|
206
|
+
courseId: z.string().describe('Blackboard course ID'),
|
|
207
|
+
contentId: z.string().describe('Content item ID'),
|
|
208
|
+
},
|
|
183
209
|
},
|
|
184
210
|
async ({ courseId, contentId }) => {
|
|
185
211
|
const { client } = getClient();
|
|
@@ -199,15 +225,22 @@ export async function startMcpServer() {
|
|
|
199
225
|
`/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
|
|
200
226
|
);
|
|
201
227
|
const body: string = r.data?.body ?? '';
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
|
|
229
|
+
// Extract <a> tags with data-bbfile — capture both the JSON metadata and the href (signed download URL)
|
|
230
|
+
// Handle both attribute orderings: data-bbfile...href and href...data-bbfile
|
|
231
|
+
const filePattern = /data-bbfile="([^"]+)"[^<]*?href="([^"]+)"|href="([^"]+)"[^<]*?data-bbfile="([^"]+)"/g;
|
|
232
|
+
const anchorMatches = [...body.matchAll(filePattern)];
|
|
233
|
+
const files = anchorMatches.map((m) => {
|
|
234
|
+
const bbfileRaw = m[1] ?? m[4];
|
|
235
|
+
const hrefRaw = m[2] ?? m[3];
|
|
204
236
|
try {
|
|
205
|
-
const meta = JSON.parse(
|
|
237
|
+
const meta = JSON.parse(bbfileRaw.replace(/"/g, '"'));
|
|
238
|
+
const downloadUrl = hrefRaw ? hrefRaw.replace(/&/g, '&') : (meta.resourceUrl ?? null);
|
|
206
239
|
return {
|
|
207
240
|
type: 'embedded',
|
|
208
241
|
displayName: meta.displayName ?? meta.linkName ?? 'unknown',
|
|
209
242
|
mimeType: meta.mimeType ?? 'application/octet-stream',
|
|
210
|
-
|
|
243
|
+
downloadUrl,
|
|
211
244
|
};
|
|
212
245
|
} catch {
|
|
213
246
|
return null;
|
|
@@ -218,7 +251,7 @@ export async function startMcpServer() {
|
|
|
218
251
|
content: [{
|
|
219
252
|
type: 'text',
|
|
220
253
|
text: JSON.stringify(
|
|
221
|
-
{ type: 'embedded_files', note: '
|
|
254
|
+
{ type: 'embedded_files', note: 'Pass downloadUrl as attachmentId to download_attachment', results: files },
|
|
222
255
|
null, 2
|
|
223
256
|
),
|
|
224
257
|
}],
|
|
@@ -227,44 +260,52 @@ export async function startMcpServer() {
|
|
|
227
260
|
);
|
|
228
261
|
|
|
229
262
|
// ── download_file_url ───────────────────────────────────────────────────────
|
|
230
|
-
server.
|
|
263
|
+
server.registerTool(
|
|
231
264
|
'download_file_url',
|
|
232
|
-
'Download a file directly from a Blackboard bbcswebdav URL (for x-bb-document embedded files). Returns base64-encoded content and filename.',
|
|
233
265
|
{
|
|
234
|
-
|
|
235
|
-
|
|
266
|
+
description: 'Download a file directly from a Blackboard bbcswebdav URL and save it to disk. Saves to outputDir (default: current working directory).',
|
|
267
|
+
inputSchema: {
|
|
268
|
+
url: z.string().describe('Direct file URL from bbcswebdav (downloadUrl from list_attachments)'),
|
|
269
|
+
filename: z.string().optional().describe('Filename to save as (e.g. displayName from list_attachments)'),
|
|
270
|
+
outputDir: z.string().optional().describe('Directory to save the file (default: current working directory)'),
|
|
271
|
+
},
|
|
236
272
|
},
|
|
237
|
-
async ({ url, filename }) => {
|
|
273
|
+
async ({ url, filename, outputDir }) => {
|
|
238
274
|
const { client } = getClient();
|
|
239
|
-
const r = await client.get(url, {
|
|
240
|
-
|
|
241
|
-
headers: { Accept: '*/*' },
|
|
242
|
-
});
|
|
243
|
-
const b64 = Buffer.from(r.data).toString('base64');
|
|
275
|
+
const r = await client.get(url, { responseType: 'arraybuffer', headers: { Accept: '*/*' } });
|
|
276
|
+
|
|
244
277
|
const contentDisposition = r.headers['content-disposition'] as string | undefined;
|
|
245
278
|
const detectedName = contentDisposition
|
|
246
279
|
? (contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/))?.[1]?.replace(/['"]/g, '').trim()
|
|
247
280
|
: undefined;
|
|
248
|
-
const finalName = filename ?? detectedName ?? '
|
|
281
|
+
const finalName = filename ?? detectedName ?? 'download';
|
|
282
|
+
|
|
283
|
+
const dir = path.resolve(outputDir ?? process.cwd());
|
|
284
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
285
|
+
const dest = path.join(dir, finalName);
|
|
286
|
+
fs.writeFileSync(dest, Buffer.from(r.data));
|
|
287
|
+
|
|
249
288
|
const mimeType = (r.headers['content-type'] as string | undefined) ?? 'application/octet-stream';
|
|
250
289
|
return {
|
|
251
290
|
content: [{
|
|
252
291
|
type: 'text',
|
|
253
|
-
text: JSON.stringify({
|
|
292
|
+
text: JSON.stringify({ saved: dest, size: r.data.byteLength, mimeType }, null, 2),
|
|
254
293
|
}],
|
|
255
294
|
};
|
|
256
295
|
}
|
|
257
296
|
);
|
|
258
297
|
|
|
259
298
|
// ── submit_attempt ──────────────────────────────────────────────────────────
|
|
260
|
-
server.
|
|
299
|
+
server.registerTool(
|
|
261
300
|
'submit_attempt',
|
|
262
|
-
'Submit an assignment attempt. ALWAYS confirm with the user before submitting.',
|
|
263
301
|
{
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
302
|
+
description: 'Submit an assignment attempt. ALWAYS confirm with the user before submitting.',
|
|
303
|
+
inputSchema: {
|
|
304
|
+
courseId: z.string().describe('Blackboard course ID'),
|
|
305
|
+
columnId: z.string().describe('Assignment (gradebook column) ID'),
|
|
306
|
+
studentComments: z.string().optional().describe('Comment to the instructor'),
|
|
307
|
+
studentSubmission: z.string().optional().describe('Text body of the submission'),
|
|
308
|
+
},
|
|
268
309
|
},
|
|
269
310
|
async ({ courseId, columnId, studentComments, studentSubmission }) => {
|
|
270
311
|
const { client } = getClient();
|
|
@@ -278,14 +319,16 @@ export async function startMcpServer() {
|
|
|
278
319
|
);
|
|
279
320
|
|
|
280
321
|
// ── raw_api ─────────────────────────────────────────────────────────────────
|
|
281
|
-
server.
|
|
322
|
+
server.registerTool(
|
|
282
323
|
'raw_api',
|
|
283
|
-
'Make a raw REST API call to Blackboard Learn. Use for any endpoint not covered by other tools.',
|
|
284
324
|
{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
325
|
+
description: 'Make a raw REST API call to Blackboard Learn. Use for any endpoint not covered by other tools.',
|
|
326
|
+
inputSchema: {
|
|
327
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
|
|
328
|
+
path: z.string().describe('API path, e.g. /learn/api/public/v1/users/me'),
|
|
329
|
+
query: z.string().optional().describe('Query string, e.g. limit=10&offset=0'),
|
|
330
|
+
body: z.string().optional().describe('JSON body string for POST/PUT/PATCH'),
|
|
331
|
+
},
|
|
289
332
|
},
|
|
290
333
|
async ({ method, path, query, body }) => {
|
|
291
334
|
const { client } = getClient();
|