blackboard-upc 1.0.2 → 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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blackboard-upc",
3
- "version": "1.0.2",
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,13 +153,15 @@ 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. 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
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, or a full bbcswebdav URL for embedded files'),
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();
@@ -174,12 +188,14 @@ export async function startMcpServer() {
174
188
  );
175
189
 
176
190
  // ── list_attachments ────────────────────────────────────────────────────────
177
- server.tool(
191
+ server.registerTool(
178
192
  '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
193
  {
181
- courseId: z.string().describe('Blackboard course ID'),
182
- 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
+ },
183
199
  },
184
200
  async ({ courseId, contentId }) => {
185
201
  const { client } = getClient();
@@ -199,15 +215,22 @@ export async function startMcpServer() {
199
215
  `/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
200
216
  );
201
217
  const body: string = r.data?.body ?? '';
202
- const matches = [...body.matchAll(/data-bbfile="([^"]+)"/g)];
203
- const files = matches.map((m) => {
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];
204
226
  try {
205
- const meta = JSON.parse(m[1].replace(/&quot;/g, '"'));
227
+ const meta = JSON.parse(bbfileRaw.replace(/&quot;/g, '"'));
228
+ const downloadUrl = hrefRaw ? hrefRaw.replace(/&amp;/g, '&') : (meta.resourceUrl ?? null);
206
229
  return {
207
230
  type: 'embedded',
208
231
  displayName: meta.displayName ?? meta.linkName ?? 'unknown',
209
232
  mimeType: meta.mimeType ?? 'application/octet-stream',
210
- resourceUrl: meta.resourceUrl ?? null,
233
+ downloadUrl,
211
234
  };
212
235
  } catch {
213
236
  return null;
@@ -218,7 +241,7 @@ export async function startMcpServer() {
218
241
  content: [{
219
242
  type: 'text',
220
243
  text: JSON.stringify(
221
- { type: 'embedded_files', note: 'Use download_file_url with resourceUrl to download', results: files },
244
+ { type: 'embedded_files', note: 'Pass downloadUrl as attachmentId to download_attachment', results: files },
222
245
  null, 2
223
246
  ),
224
247
  }],
@@ -227,12 +250,14 @@ export async function startMcpServer() {
227
250
  );
228
251
 
229
252
  // ── download_file_url ───────────────────────────────────────────────────────
230
- server.tool(
253
+ server.registerTool(
231
254
  '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
255
  {
234
- url: z.string().describe('Direct file URL from bbcswebdav (resourceUrl from content body)'),
235
- filename: z.string().optional().describe('Desired filename for saving the file'),
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
+ },
236
261
  },
237
262
  async ({ url, filename }) => {
238
263
  const { client } = getClient();
@@ -257,14 +282,16 @@ export async function startMcpServer() {
257
282
  );
258
283
 
259
284
  // ── submit_attempt ──────────────────────────────────────────────────────────
260
- server.tool(
285
+ server.registerTool(
261
286
  'submit_attempt',
262
- 'Submit an assignment attempt. ALWAYS confirm with the user before submitting.',
263
287
  {
264
- courseId: z.string().describe('Blackboard course ID'),
265
- columnId: z.string().describe('Assignment (gradebook column) ID'),
266
- studentComments: z.string().optional().describe('Comment to the instructor'),
267
- 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
+ },
268
295
  },
269
296
  async ({ courseId, columnId, studentComments, studentSubmission }) => {
270
297
  const { client } = getClient();
@@ -278,14 +305,16 @@ export async function startMcpServer() {
278
305
  );
279
306
 
280
307
  // ── raw_api ─────────────────────────────────────────────────────────────────
281
- server.tool(
308
+ server.registerTool(
282
309
  'raw_api',
283
- 'Make a raw REST API call to Blackboard Learn. Use for any endpoint not covered by other tools.',
284
310
  {
285
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
286
- path: z.string().describe('API path, e.g. /learn/api/public/v1/users/me'),
287
- query: z.string().optional().describe('Query string, e.g. limit=10&offset=0'),
288
- 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
+ },
289
318
  },
290
319
  async ({ method, path, query, body }) => {
291
320
  const { client } = getClient();