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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blackboard-upc",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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
@@ -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.tool('whoami', 'Get the currently authenticated UPC student info', {}, async () => {
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.tool('system_version', 'Get Blackboard Learn server version', {}, async () => {
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.tool('list_courses', 'List all enrolled courses for the current student', {}, async () => {
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.tool(
58
+ server.registerTool(
57
59
  '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') },
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.tool(
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
- courseId: z.string().describe('Blackboard course ID'),
73
- parentId: z.string().optional().describe('Parent folder content ID (omit for root level)'),
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.tool(
89
+ server.registerTool(
84
90
  'list_announcements',
85
- 'List recent announcements for a course',
86
- { courseId: z.string().describe('Blackboard course ID') },
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.tool(
103
+ server.registerTool(
96
104
  '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') },
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.tool(
117
+ server.registerTool(
108
118
  'list_attempts',
109
- 'List submission attempts for a specific assignment (gradebook column)',
110
119
  {
111
- courseId: z.string().describe('Blackboard course ID'),
112
- columnId: z.string().describe('Gradebook column ID (assignment ID)'),
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.tool(
134
+ server.registerTool(
123
135
  'get_grades',
124
- 'Get all grades for the current student in a course',
125
- { courseId: z.string().describe('Blackboard course ID') },
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.tool(
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
- 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'),
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
- const b64 = Buffer.from(r.data).toString('base64');
178
+
162
179
  const contentDisposition = r.headers['content-disposition'] as string | undefined;
163
- const filename = contentDisposition
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({ filename: filename ?? 'file', mimeType, size: r.data.byteLength, base64: b64 }, null, 2),
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.tool(
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
- courseId: z.string().describe('Blackboard course ID'),
182
- contentId: z.string().describe('Content item ID'),
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
- const matches = [...body.matchAll(/data-bbfile="([^"]+)"/g)];
203
- const files = matches.map((m) => {
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(m[1].replace(/&quot;/g, '"'));
237
+ const meta = JSON.parse(bbfileRaw.replace(/&quot;/g, '"'));
238
+ const downloadUrl = hrefRaw ? hrefRaw.replace(/&amp;/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
- resourceUrl: meta.resourceUrl ?? null,
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: 'Use download_file_url with resourceUrl to download', results: files },
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.tool(
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
- 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'),
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
- responseType: 'arraybuffer',
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 ?? 'file';
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({ filename: finalName, mimeType, size: r.data.byteLength, base64: b64 }, null, 2),
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.tool(
299
+ server.registerTool(
261
300
  'submit_attempt',
262
- 'Submit an assignment attempt. ALWAYS confirm with the user before submitting.',
263
301
  {
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'),
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.tool(
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
- 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'),
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();