blackboard-upc 1.0.4 → 1.0.7

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,54 @@ All notable changes to `blackboard-upc` will be documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.0.7] — 2026-04-12
8
+
9
+ ### Added
10
+ - **Persistent browser context** — el perfil de Playwright se guarda en `~/.blackboard-cli/browser-profile/`; las cookies de Microsoft SSO persisten entre sesiones, eliminando el login manual repetido
11
+ - **Silent auto-refresh** — cuando la sesión expira, la CLI relanza el browser en headless y se re-autentica automáticamente si el SSO de Microsoft sigue activo (sin intervención del usuario)
12
+ - **TTL real del servidor** — la expiración de sesión ya no es hardcoded a 8h; se parsea el campo `expires` del cookie `BbRouter` para usar el timestamp real del servidor (fallback: 3h)
13
+ - `get_assignment_feedback` — muestra nota, comentarios del profesor y archivos de feedback para todas las tareas de un curso
14
+ - `download_feedback_file` *(experimental)* — descarga archivos adjuntados por el profesor a una corrección
15
+
16
+ ### Changed
17
+ - `blackboard login` ahora muestra el tiempo de expiración real (ej. "expira en 2.9h") en vez de "8 horas"
18
+ - `whoami`, `status` y `api` usan `loadOrRefreshSession()` — intentan refresh silencioso antes de pedir login manual
19
+
20
+ ---
21
+
22
+ ## [1.0.6] — 2026-04-12
23
+
24
+ ### Added
25
+ - Soporte completo para quizzes/evaluaciones de Blackboard Ultra:
26
+ - `get_quiz_questions` — obtiene todas las preguntas, opciones y respuesta actual de un intento; acepta URL directa o IDs separados (`courseId` + `contentId` + `attemptId`)
27
+ - `save_quiz_answer` — guarda una respuesta individual sin enviar (verdadero/falso o índice de opción)
28
+ - `submit_quiz` — envía el intento final (siempre pide confirmación)
29
+ - `src/api/quiz.ts` — módulo nuevo con tipos `QuizQuestion`, `QuizInfo`, `QuizAttemptPolicy` y toda la lógica de los endpoints internos de Ultra
30
+ - Verifica intentos restantes antes de cargar preguntas (`getQuizColumnId`)
31
+
32
+ ### Fixed
33
+ - `tsconfig.json` — agrega `"DOM"` a `lib` para que los callbacks de `page.evaluate()` en `login.ts` compilen sin errores
34
+
35
+ ---
36
+
37
+ ## [1.0.5] — 2026-03-31
38
+
39
+ ### Fixed
40
+ - `run.js` ahora prepone el directorio del Node que lo ejecuta al PATH antes de lanzar `tsx`
41
+ - Soluciona el crash en MCP cuando el usuario tiene nvm con Node 16 como default (Playwright requiere >=18)
42
+
43
+ ---
44
+
45
+ ## [1.0.4] — 2026-03-30
46
+
47
+ ### Changed
48
+ - `download_attachment` y `download_file_url` ya no devuelven base64 — guardan el archivo directamente a disco
49
+ - Directorio por defecto: `process.cwd()` (donde el usuario está trabajando), configurable con `outputDir`
50
+ - Pasar `filename` (el `displayName` de `list_attachments`) para guardar con el nombre correcto
51
+ - Respuesta devuelve `{ saved, size, mimeType }` — sin datos en el contexto
52
+
53
+ ---
54
+
7
55
  ## [1.0.3] — 2026-03-30
8
56
 
9
57
  ### Changed
package/CLAUDE.md CHANGED
@@ -24,9 +24,25 @@ If you get `Not authenticated`, ask the user to run `blackboard login`.
24
24
  6. list_attachments <courseId> <contentId>→ find downloadable files
25
25
  ```
26
26
 
27
+ ### Quiz workflow
28
+
29
+ ```
30
+ 1. list_contents <courseId> → find the quiz contentId
31
+ 2. get_quiz_questions <url|ids> → load questions + options + attempt policy
32
+ 3. save_quiz_answer (per question) → save each answer individually
33
+ 4. submit_quiz (confirm first!) → finalize and submit the attempt
34
+ ```
35
+
36
+ ### Feedback workflow
37
+
38
+ ```
39
+ 1. get_assignment_feedback <courseId> → scores + instructor comments + feedback files for all assignments
40
+ 2. download_feedback_file <ids> → download an annotated file the professor attached to the grade
41
+ ```
42
+
27
43
  ## Agent behavior rules
28
44
 
29
- - **Always confirm before submitting** (`submit_attempt`). Show the user what will be submitted and ask for confirmation. Never submit silently.
45
+ - **Always confirm before submitting** (`submit_attempt`, `submit_quiz`). Show the user what will be submitted and ask for confirmation. Never submit silently.
30
46
  - **Show grades in context** — when showing grades, also show the assignment name, max score, and due date if available.
31
47
  - **Navigate content recursively** — if the user asks for materials, explore subfolders using `list_contents` with `parentId`.
32
48
  - **Use `raw_api` for anything not covered** — the Blackboard REST API is extensive. If there's no specific tool, use `raw_api` with the correct endpoint.
@@ -65,6 +81,12 @@ GET /learn/api/public/v1/courses/{courseId}/contents/{id}/attachments/{id}/downl
65
81
  | `list_attempts` | Submission history |
66
82
  | `get_grades` | Full grade report for a course |
67
83
  | `list_attachments` | Files in a content item |
68
- | `download_attachment` | Download file (base64) |
84
+ | `download_attachment` | Download file to disk |
85
+ | `download_file_url` | Download a bbcswebdav URL directly |
69
86
  | `submit_attempt` | Submit assignment (confirm first!) |
87
+ | `get_assignment_feedback` | Scores + instructor comments + feedback files for all assignments in a course |
88
+ | `download_feedback_file` | **[EXPERIMENTAL]** Download a file the professor attached to a graded attempt |
89
+ | `get_quiz_questions` | Load quiz questions + options from an attempt (URL or IDs) |
90
+ | `save_quiz_answer` | Save one answer without submitting |
91
+ | `submit_quiz` | Finalize and submit a quiz attempt (confirm first!) |
70
92
  | `raw_api` | Any other Blackboard endpoint |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blackboard-upc",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
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/run.js CHANGED
@@ -4,5 +4,7 @@ const { spawnSync } = require('child_process');
4
4
  const path = require('path');
5
5
  const tsx = path.join(__dirname, 'node_modules', '.bin', 'tsx');
6
6
  const entry = path.join(__dirname, 'src', 'index.ts');
7
- const result = spawnSync(tsx, [entry, ...process.argv.slice(2)], { stdio: 'inherit' });
7
+ const nodeDir = path.dirname(process.execPath);
8
+ const env = { ...process.env, PATH: `${nodeDir}${path.delimiter}${process.env.PATH}` };
9
+ const result = spawnSync(tsx, [entry, ...process.argv.slice(2)], { stdio: 'inherit', env });
8
10
  process.exit(result.status ?? 0);
@@ -33,6 +33,17 @@ export interface Attempt {
33
33
  modified?: string;
34
34
  attemptDate?: string;
35
35
  files?: Array<{ id: string; fileName: string; mimeType: string }>;
36
+ // Instructor feedback fields
37
+ instructorFeedback?: string;
38
+ feedback?: string;
39
+ }
40
+
41
+ export interface AttemptFile {
42
+ id: string;
43
+ name: string;
44
+ mimeType?: string;
45
+ size?: number;
46
+ href?: string;
36
47
  }
37
48
 
38
49
  export interface SubmitAttemptBody {
@@ -122,6 +133,18 @@ export async function submitAttempt(
122
133
  return r.data;
123
134
  }
124
135
 
136
+ export async function getAttemptFiles(
137
+ client: AxiosInstance,
138
+ courseId: string,
139
+ columnId: string,
140
+ attemptId: string
141
+ ): Promise<AttemptFile[]> {
142
+ const r = await client.get(
143
+ `/learn/api/public/v2/courses/${courseId}/gradebook/columns/${columnId}/attempts/${attemptId}/files`
144
+ );
145
+ return r.data.results ?? [];
146
+ }
147
+
125
148
  export async function getMyGrade(
126
149
  client: AxiosInstance,
127
150
  courseId: string,
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Quiz / Assessment support for Blackboard Ultra.
3
+ *
4
+ * All endpoints discovered by intercepting the Ultra SPA network traffic:
5
+ *
6
+ * GET /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}
7
+ * ?columnId={columnId}&expand=toolAttemptDetail,alignedGoals
8
+ * → Returns full question data in toolAttemptDetail["resource/x-bb-assessment"].questionAttempts
9
+ *
10
+ * PATCH /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}/assessment/answers/{questionAttemptId}
11
+ * → Saves a single answer (eitherOr or multipleanswer)
12
+ *
13
+ * PATCH /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}
14
+ * ?autoSubmitted=false&expand=attemptReceipt.lateSubmission
15
+ * → Final submit
16
+ */
17
+
18
+ import type { AxiosInstance } from 'axios';
19
+
20
+ // ── Types ────────────────────────────────────────────────────────────────────
21
+
22
+ export interface QuizOption {
23
+ id: string;
24
+ text: string;
25
+ index: number;
26
+ }
27
+
28
+ export type QuizQuestionType = 'eitherOr' | 'multipleanswer' | 'presentation' | string;
29
+
30
+ export interface QuizQuestion {
31
+ /** Question attempt ID — used as the URL segment for saving answers */
32
+ questionAttemptId: string;
33
+ /** Question definition ID */
34
+ questionId: string;
35
+ /** Position within the quiz (1-based visible number) */
36
+ position: number;
37
+ /** 'eitherOr' = true/false, 'multipleanswer' = MC, 'presentation' = text only */
38
+ type: QuizQuestionType;
39
+ /** Plain text of the question (HTML stripped) */
40
+ text: string;
41
+ points: number;
42
+ /** Answer options (present for eitherOr and multipleanswer) */
43
+ options?: QuizOption[];
44
+ /** Currently saved answer:
45
+ * - eitherOr: true | false | null
46
+ * - multipleanswer: boolean[] (one per option, in options order)
47
+ */
48
+ currentAnswer?: boolean | boolean[] | null;
49
+ /** Raw question object from API (needed when saving eitherOr answers) */
50
+ _raw: any;
51
+ }
52
+
53
+ export interface QuizInfo {
54
+ attemptId: string;
55
+ courseId: string;
56
+ columnId: string;
57
+ title: string;
58
+ status: string;
59
+ totalPoints: number;
60
+ questions: QuizQuestion[];
61
+ }
62
+
63
+ // ── Helpers ──────────────────────────────────────────────────────────────────
64
+
65
+ function stripHtml(html: string): string {
66
+ return html
67
+ .replace(/<!--[^>]*-->/g, '')
68
+ .replace(/<[^>]+>/g, ' ')
69
+ .replace(/&nbsp;/g, ' ')
70
+ .replace(/\s+/g, ' ')
71
+ .trim();
72
+ }
73
+
74
+ function parseQuestionAttempt(qa: any, idx: number): QuizQuestion {
75
+ const q = qa.question || {};
76
+ const text = stripHtml(q.questionText?.rawText || q.questionText?.displayText || '');
77
+
78
+ let options: QuizOption[] | undefined;
79
+
80
+ if (qa.questionType === 'eitherOr') {
81
+ // True/False — always two options
82
+ options = [
83
+ { id: 'true', text: 'Verdadero', index: 0 },
84
+ { id: 'false', text: 'Falso', index: 1 },
85
+ ];
86
+ } else if (qa.questionType === 'multipleanswer' && Array.isArray(q.answers)) {
87
+ options = q.answers.map((a: any, i: number) => ({
88
+ id: a.id,
89
+ text: stripHtml(a.answerText?.rawText || a.answerText?.displayText || ''),
90
+ index: i,
91
+ }));
92
+ }
93
+
94
+ return {
95
+ questionAttemptId: qa.id,
96
+ questionId: qa.questionId || q.id,
97
+ position: qa.visibleQuestionNumber ?? idx + 1,
98
+ type: qa.questionType,
99
+ text,
100
+ points: q.points ?? 0,
101
+ options,
102
+ currentAnswer: qa.givenAnswer ?? null,
103
+ _raw: qa,
104
+ };
105
+ }
106
+
107
+ // ── Get quiz questions ────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Fetch all questions for a quiz attempt via the internal Blackboard API.
111
+ *
112
+ * @param courseId e.g. _529533_1
113
+ * @param columnId Gradebook column ID — from content item contentHandler.gradeColumnId
114
+ * @param attemptId e.g. _94898825_1
115
+ */
116
+ export async function getQuizQuestions(
117
+ client: AxiosInstance,
118
+ courseId: string,
119
+ columnId: string,
120
+ attemptId: string
121
+ ): Promise<QuizInfo> {
122
+ const r = await client.get(
123
+ `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}`,
124
+ { params: { columnId, expand: 'toolAttemptDetail,alignedGoals' } }
125
+ );
126
+
127
+ const data = r.data;
128
+ const detail = data.toolAttemptDetail?.['resource/x-bb-assessment'];
129
+
130
+ if (!detail) {
131
+ throw new Error(
132
+ `No toolAttemptDetail found for attempt ${attemptId}. ` +
133
+ `Make sure the columnId (${columnId}) and courseId are correct.`
134
+ );
135
+ }
136
+
137
+ const rawQuestions: any[] = detail.questionAttempts || [];
138
+ // Filter out presentation-only questions (no points, no interaction)
139
+ const answerableQuestions = rawQuestions.filter(
140
+ (qa) => qa.questionType !== 'presentation'
141
+ );
142
+
143
+ const questions = answerableQuestions.map(parseQuestionAttempt);
144
+
145
+ return {
146
+ attemptId,
147
+ courseId,
148
+ columnId,
149
+ title: detail.assessment?.title || 'Quiz',
150
+ status: detail.status || data.status || 'IN_PROGRESS',
151
+ totalPoints: detail.possiblePoints || detail.assessment?.totalPoints || 0,
152
+ questions,
153
+ };
154
+ }
155
+
156
+ // ── Save a single answer ──────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Save one answer for a quiz question.
160
+ *
161
+ * @param courseId Course ID
162
+ * @param attemptId Quiz attempt ID
163
+ * @param question The question object from getQuizQuestions
164
+ * @param answer
165
+ * - eitherOr: boolean (true = Verdadero, false = Falso)
166
+ * - multipleanswer: number (0-based index of the selected option)
167
+ * OR boolean[] (one per option)
168
+ */
169
+ export async function saveQuizAnswer(
170
+ client: AxiosInstance,
171
+ courseId: string,
172
+ attemptId: string,
173
+ question: QuizQuestion,
174
+ answer: boolean | number | boolean[]
175
+ ): Promise<any> {
176
+ const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}/assessment/answers/${question.questionAttemptId}`;
177
+
178
+ let body: any;
179
+
180
+ if (question.type === 'eitherOr') {
181
+ const givenAnswer = typeof answer === 'boolean' ? answer : Boolean(answer);
182
+ body = {
183
+ questionType: 'eitherOr',
184
+ givenAnswer,
185
+ question: question._raw.question,
186
+ };
187
+ } else if (question.type === 'multipleanswer') {
188
+ const optionCount = question.options?.length ?? 0;
189
+ let givenAnswer: boolean[];
190
+
191
+ if (Array.isArray(answer)) {
192
+ givenAnswer = answer as boolean[];
193
+ } else {
194
+ // Convert index to boolean array
195
+ const idx = typeof answer === 'number' ? answer : 0;
196
+ givenAnswer = Array.from({ length: optionCount }, (_, i) => i === idx);
197
+ }
198
+
199
+ body = {
200
+ questionType: 'multipleanswer',
201
+ givenAnswer,
202
+ lookupOrder: question._raw.lookupOrder || [],
203
+ order: question._raw.order || [],
204
+ question: question._raw.question,
205
+ };
206
+ } else {
207
+ throw new Error(`Unsupported question type: ${question.type}`);
208
+ }
209
+
210
+ const r = await client.patch(url, body);
211
+ return r.data;
212
+ }
213
+
214
+ // ── Submit the quiz attempt ────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Finalize and submit a quiz attempt.
218
+ * ALWAYS ask the user to confirm before calling this.
219
+ */
220
+ export async function submitQuizAttempt(
221
+ client: AxiosInstance,
222
+ courseId: string,
223
+ attemptId: string
224
+ ): Promise<any> {
225
+ const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}`;
226
+ const r = await client.patch(url, {
227
+ toolAttemptDetail: { 'resource/x-bb-assessment': { type: 'Test' } },
228
+ status: 'NEEDS_GRADING',
229
+ studentSubmission: null,
230
+ }, {
231
+ params: {
232
+ autoSubmitted: 'false',
233
+ expand: 'attemptReceipt.lateSubmission',
234
+ },
235
+ });
236
+ return r.data;
237
+ }
238
+
239
+ // ── Get quiz column ID + attempt limits ───────────────────────────────────────
240
+
241
+ export interface QuizAttemptPolicy {
242
+ columnId: string;
243
+ assessmentId: string;
244
+ title: string;
245
+ /** Max attempts allowed. 0 = unlimited. */
246
+ attemptsAllowed: number;
247
+ /** Attempts left for this student. -1 = unlimited, 0 = none left. */
248
+ attemptsLeft: number;
249
+ /** Human-readable summary, e.g. "Ilimitados" or "2 de 3 restantes" */
250
+ attemptSummary: string;
251
+ /** true = safe to proceed, false = no attempts left */
252
+ canAttempt: boolean;
253
+ }
254
+
255
+ /**
256
+ * Resolve the gradebook column ID for a quiz content item AND fetch
257
+ * the attempt policy (max attempts + attempts left) for the current user.
258
+ */
259
+ export async function getQuizColumnId(
260
+ client: AxiosInstance,
261
+ courseId: string,
262
+ contentId: string,
263
+ userId?: string
264
+ ): Promise<QuizAttemptPolicy> {
265
+ // 1. Content item → columnId + assessmentId
266
+ const contentR = await client.get(
267
+ `/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
268
+ );
269
+ const content = contentR.data;
270
+ const handler = content.contentHandler;
271
+
272
+ if (!handler?.gradeColumnId) {
273
+ throw new Error(
274
+ `Content item ${contentId} does not have a gradeColumnId. ` +
275
+ `Is it a quiz (resource/x-bb-asmt-test-link)?`
276
+ );
277
+ }
278
+
279
+ const columnId: string = handler.gradeColumnId;
280
+ const assessmentId: string = handler.assessmentId;
281
+ const title: string = content.title || 'Quiz';
282
+
283
+ // 2. Gradebook column → attemptsAllowed (0 = unlimited)
284
+ const colR = await client.get(
285
+ `/learn/api/public/v2/courses/${courseId}/gradebook/columns/${columnId}`
286
+ );
287
+ const attemptsAllowed: number = colR.data?.grading?.attemptsAllowed ?? 0;
288
+
289
+ // 3. Student grade → attemptsLeft (-1 = unlimited)
290
+ let attemptsLeft = -1;
291
+ if (userId) {
292
+ try {
293
+ const gradeR = await client.get(
294
+ `/learn/api/v1/courses/${courseId}/gradebook/columns/${columnId}/grades`,
295
+ { params: { expand: 'attemptsLeft', userId } }
296
+ );
297
+ const grade = gradeR.data?.results?.[0];
298
+ if (grade?.attemptsLeft !== undefined) {
299
+ attemptsLeft = grade.attemptsLeft;
300
+ }
301
+ } catch {
302
+ // non-fatal: fall back to attemptsAllowed logic
303
+ }
304
+ }
305
+
306
+ // 4. Derive human-readable summary and canAttempt flag
307
+ const unlimited = attemptsAllowed === 0 || attemptsLeft === -1;
308
+ const canAttempt = unlimited || attemptsLeft > 0;
309
+
310
+ let attemptSummary: string;
311
+ if (unlimited) {
312
+ attemptSummary = 'Ilimitados';
313
+ } else if (attemptsLeft === 0) {
314
+ attemptSummary = `Sin intentos restantes (máximo: ${attemptsAllowed})`;
315
+ } else {
316
+ const used = attemptsAllowed - attemptsLeft;
317
+ attemptSummary = `${attemptsLeft} de ${attemptsAllowed} restantes (${used} enviados)`;
318
+ }
319
+
320
+ return {
321
+ columnId,
322
+ assessmentId,
323
+ title,
324
+ attemptsAllowed,
325
+ attemptsLeft,
326
+ attemptSummary,
327
+ canAttempt,
328
+ };
329
+ }
330
+
331
+ // ── Parse URL helper ──────────────────────────────────────────────────────────
332
+
333
+ /**
334
+ * Parse a Blackboard Ultra quiz URL into its component IDs.
335
+ *
336
+ * Handles:
337
+ * /ultra/stream/assessment/{contentId}/overview/attempt/{attemptId}?courseId={courseId}
338
+ * /ultra/stream/assessment/{contentId}/take/attempt/{attemptId}?courseId={courseId}
339
+ */
340
+ export function parseQuizUrl(urlStr: string): {
341
+ contentId?: string;
342
+ attemptId?: string;
343
+ courseId?: string;
344
+ } {
345
+ try {
346
+ const u = new URL(
347
+ urlStr.startsWith('http')
348
+ ? urlStr
349
+ : `https://aulavirtual.upc.edu.pe${urlStr}`
350
+ );
351
+ const parts = u.pathname.split('/');
352
+ const assessmentIdx = parts.indexOf('assessment');
353
+ const attemptIdx = parts.indexOf('attempt');
354
+
355
+ return {
356
+ contentId: assessmentIdx >= 0 ? parts[assessmentIdx + 1] : undefined,
357
+ attemptId: attemptIdx >= 0 ? parts[attemptIdx + 1] : undefined,
358
+ courseId: u.searchParams.get('courseId') ?? undefined,
359
+ };
360
+ } catch {
361
+ return {};
362
+ }
363
+ }
package/src/auth/login.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import { chromium } from 'playwright';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
2
5
  import type { Session, Cookie } from '../types/index.js';
3
6
  import { saveSession } from './session.js';
4
7
 
5
8
  const BASE_URL = 'https://aulavirtual.upc.edu.pe';
6
9
  const SAML_URL = `${BASE_URL}/auth-saml/saml/login?apId=_4893_1&redirectUrl=${encodeURIComponent(`${BASE_URL}/ultra`)}`;
7
10
 
8
- // Session duration: 8 hours
9
- const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
11
+ const SESSION_TTL_FALLBACK_MS = 3 * 60 * 60 * 1000; // 3h matches BbRouter timeout:10800
12
+ const PROFILE_DIR = path.join(os.homedir(), '.blackboard-cli', 'browser-profile');
13
+
14
+ const USER_AGENT =
15
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
16
+ '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
10
17
 
11
18
  export interface LoginOptions {
12
19
  headless?: boolean;
@@ -15,14 +22,50 @@ export interface LoginOptions {
15
22
  timeout?: number;
16
23
  }
17
24
 
25
+ export class SilentLoginFailed extends Error {
26
+ constructor(reason: string) {
27
+ super(`Silent re-login failed: ${reason}`);
28
+ this.name = 'SilentLoginFailed';
29
+ }
30
+ }
31
+
32
+ function extractBbRouterExpiry(cookies: Cookie[]): number {
33
+ const bb = cookies.find(c => c.name === 'BbRouter');
34
+ if (bb) {
35
+ const m = bb.value.match(/expires:(\d+)/);
36
+ if (m) {
37
+ const ms = parseInt(m[1], 10) * 1000;
38
+ const now = Date.now();
39
+ // Sanity check: must be a future timestamp within 24 hours
40
+ if (ms > now && ms < now + 24 * 60 * 60 * 1000) return ms;
41
+ }
42
+ }
43
+ return Date.now() + SESSION_TTL_FALLBACK_MS;
44
+ }
45
+
46
+ function extractXsrf(cookies: Cookie[]): string {
47
+ const bb = cookies.find(c => c.name === 'BbRouter');
48
+ if (bb) {
49
+ const m = bb.value.match(/xsrf:([a-f0-9-]+)/);
50
+ if (m) return m[1];
51
+ }
52
+ return '';
53
+ }
54
+
55
+ function ensureProfileDir(): void {
56
+ if (!fs.existsSync(PROFILE_DIR)) {
57
+ fs.mkdirSync(PROFILE_DIR, { recursive: true, mode: 0o700 });
58
+ }
59
+ }
60
+
18
61
  export async function login(opts: LoginOptions = {}): Promise<Session> {
19
62
  const { headless = false, timeout = 120_000 } = opts;
20
63
 
21
- const browser = await chromium.launch({ headless });
22
- const context = await browser.newContext({
23
- userAgent:
24
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
25
- '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
64
+ ensureProfileDir();
65
+
66
+ const context = await chromium.launchPersistentContext(PROFILE_DIR, {
67
+ headless,
68
+ userAgent: USER_AGENT,
26
69
  });
27
70
 
28
71
  const page = await context.newPage();
@@ -31,28 +74,36 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
31
74
  console.log('Navigating to UPC Aula Virtual...');
32
75
  await page.goto(SAML_URL, { waitUntil: 'networkidle', timeout });
33
76
 
34
- // Wait for Microsoft login page
35
- await page.waitForURL(/login\.microsoftonline\.com/, { timeout: 30_000 });
36
-
37
- if (opts.username) {
38
- await page.fill('input[type="email"], input[name="loginfmt"]', opts.username);
39
- await page.click('input[type="submit"], button[type="submit"]');
40
- await page.waitForTimeout(1500);
77
+ // With persistent context, Microsoft SSO may auto-complete without showing the login page
78
+ let needsInteractiveLogin = false;
79
+ try {
80
+ await page.waitForURL(/login\.microsoftonline\.com/, { timeout: 8_000 });
81
+ needsInteractiveLogin = true;
82
+ } catch {
83
+ // SSO cookies still valid — SAML redirect auto-completing
41
84
  }
42
85
 
43
- if (opts.password) {
44
- await page.waitForSelector('input[type="password"], input[name="passwd"]', { timeout: 15_000 });
45
- await page.fill('input[type="password"], input[name="passwd"]', opts.password);
46
- await page.click('input[type="submit"], button[type="submit"]');
47
- await page.waitForTimeout(1500);
48
- }
86
+ if (needsInteractiveLogin) {
87
+ if (opts.username) {
88
+ await page.fill('input[type="email"], input[name="loginfmt"]', opts.username);
89
+ await page.click('input[type="submit"], button[type="submit"]');
90
+ await page.waitForTimeout(1500);
91
+ }
49
92
 
50
- // Handle "Stay signed in?" prompt
51
- try {
52
- await page.waitForSelector('#idBtn_Back, #KmsiCheckboxField', { timeout: 8_000 });
53
- const noBtn = page.locator('#idBtn_Back');
54
- if (await noBtn.isVisible()) await noBtn.click();
55
- } catch {}
93
+ if (opts.password) {
94
+ await page.waitForSelector('input[type="password"], input[name="passwd"]', { timeout: 15_000 });
95
+ await page.fill('input[type="password"], input[name="passwd"]', opts.password);
96
+ await page.click('input[type="submit"], button[type="submit"]');
97
+ await page.waitForTimeout(1500);
98
+ }
99
+
100
+ // Handle "Stay signed in?" prompt
101
+ try {
102
+ await page.waitForSelector('#idBtn_Back, #KmsiCheckboxField', { timeout: 8_000 });
103
+ const noBtn = page.locator('#idBtn_Back');
104
+ if (await noBtn.isVisible()) await noBtn.click();
105
+ } catch {}
106
+ }
56
107
 
57
108
  // Wait for redirect back to aulavirtual.upc.edu.pe/ultra
58
109
  console.log('Waiting for authentication to complete...');
@@ -75,37 +126,23 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
75
126
  sameSite: c.sameSite,
76
127
  }));
77
128
 
78
- // Extract XSRF token from page
79
- const xsrfToken = await page.evaluate(() => {
80
- // Try various sources
81
- const metaXsrf = document.querySelector<HTMLMetaElement>(
82
- 'meta[name="blackboard.platform.security.NonceUtil.nonce"]'
83
- )?.content;
84
- if (metaXsrf) return metaXsrf;
85
-
86
- // Try from cookies
87
- const allCookies = document.cookie.split(';').reduce<Record<string, string>>((acc, c) => {
88
- const [k, v] = c.trim().split('=');
89
- acc[k] = v;
90
- return acc;
91
- }, {});
92
- return allCookies['XSRF-TOKEN'] || '';
93
- });
129
+ // Extract XSRF token from BbRouter cookie
130
+ let nonce = extractXsrf(cookies);
94
131
 
95
- // Get nonce from a page that has it
96
- let nonce = xsrfToken;
132
+ // Fallback: try meta tag in the page
97
133
  if (!nonce) {
98
- const loginResp = await page.goto(`${BASE_URL}/webapps/login/`, { waitUntil: 'domcontentloaded' });
99
- if (loginResp) {
100
- nonce = await page.evaluate(() => {
101
- return (
102
- document.querySelector<HTMLInputElement>(
103
- 'input[name="blackboard.platform.security.NonceUtil.nonce.ajax"]'
104
- )?.value || ''
105
- );
106
- });
107
- await page.goBack();
108
- }
134
+ nonce = await page.evaluate(() => {
135
+ const metaXsrf = document.querySelector<HTMLMetaElement>(
136
+ 'meta[name="blackboard.platform.security.NonceUtil.nonce"]'
137
+ )?.content;
138
+ if (metaXsrf) return metaXsrf;
139
+ const allCookies = document.cookie.split(';').reduce<Record<string, string>>((acc, c) => {
140
+ const [k, v] = c.trim().split('=');
141
+ acc[k] = v;
142
+ return acc;
143
+ }, {});
144
+ return allCookies['XSRF-TOKEN'] || '';
145
+ });
109
146
  }
110
147
 
111
148
  // Get current user info via direct HTTP call with captured cookies
@@ -126,7 +163,7 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
126
163
  xsrfToken: nonce,
127
164
  userId: userData?.id,
128
165
  userName: userData?.userName,
129
- expiresAt: Date.now() + SESSION_TTL_MS,
166
+ expiresAt: extractBbRouterExpiry(cookies),
130
167
  };
131
168
 
132
169
  saveSession(session);
@@ -134,6 +171,64 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
134
171
 
135
172
  return session;
136
173
  } finally {
137
- await browser.close();
174
+ await context.close();
175
+ }
176
+ }
177
+
178
+ export async function silentRelogin(previousSession?: Session | null): Promise<Session> {
179
+ if (!fs.existsSync(PROFILE_DIR)) {
180
+ throw new SilentLoginFailed('No browser profile — run blackboard login first');
181
+ }
182
+
183
+ let context;
184
+ try {
185
+ context = await chromium.launchPersistentContext(PROFILE_DIR, {
186
+ headless: true,
187
+ userAgent: USER_AGENT,
188
+ });
189
+ } catch (err: any) {
190
+ throw new SilentLoginFailed(`Could not open browser profile: ${err.message}`);
191
+ }
192
+
193
+ const page = await context.newPage();
194
+
195
+ try {
196
+ await page.goto(SAML_URL, { waitUntil: 'commit', timeout: 20_000 });
197
+
198
+ // If SSO cookies are still valid, this redirect completes automatically
199
+ await page.waitForURL(/aulavirtual\.upc\.edu\.pe\/ultra/, { timeout: 15_000 });
200
+ await page.waitForLoadState('networkidle', { timeout: 10_000 });
201
+
202
+ const rawCookies = await context.cookies();
203
+ const cookies: Cookie[] = rawCookies.map((c) => ({
204
+ name: c.name,
205
+ value: c.value,
206
+ domain: c.domain,
207
+ path: c.path,
208
+ expires: c.expires,
209
+ httpOnly: c.httpOnly,
210
+ secure: c.secure,
211
+ sameSite: c.sameSite,
212
+ }));
213
+
214
+ if (!cookies.some(c => c.name === 'JSESSIONID' || c.name === 'BbRouter')) {
215
+ throw new SilentLoginFailed('Redirect succeeded but session cookies are missing');
216
+ }
217
+
218
+ const session: Session = {
219
+ cookies,
220
+ xsrfToken: extractXsrf(cookies),
221
+ userId: previousSession?.userId,
222
+ userName: previousSession?.userName,
223
+ expiresAt: extractBbRouterExpiry(cookies),
224
+ };
225
+
226
+ saveSession(session);
227
+ return session;
228
+ } catch (err: any) {
229
+ if (err instanceof SilentLoginFailed) throw err;
230
+ throw new SilentLoginFailed(err.message ?? 'Timed out — SSO session likely expired');
231
+ } finally {
232
+ await context.close();
138
233
  }
139
234
  }
@@ -42,3 +42,25 @@ export function isSessionValid(session: Session | null): boolean {
42
42
  session.cookies.some(c => c.name === 'BbRouter');
43
43
  return hasCriticalCookies;
44
44
  }
45
+
46
+ export async function loadOrRefreshSession(): Promise<Session | null> {
47
+ // 1. Session still valid — return directly
48
+ const session = loadSession();
49
+ if (session !== null) return session;
50
+
51
+ // 2. Session expired — read raw file to preserve userId/userName for silent refresh
52
+ let expiredSession: Session | null = null;
53
+ try {
54
+ const raw = fs.readFileSync(SESSION_FILE, 'utf-8');
55
+ expiredSession = JSON.parse(raw);
56
+ } catch {}
57
+
58
+ // Dynamic import to avoid circular dependency (login.ts imports from session.ts)
59
+ const { silentRelogin, SilentLoginFailed } = await import('./login.js');
60
+ try {
61
+ return await silentRelogin(expiredSession);
62
+ } catch (err) {
63
+ if (err instanceof SilentLoginFailed) return null; // SSO expired — caller must prompt for login
64
+ throw err;
65
+ }
66
+ }
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
3
3
  import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import { login } from '../auth/login.js';
6
- import { loadSession, clearSession, isSessionValid } from '../auth/session.js';
6
+ import { loadSession, loadOrRefreshSession, clearSession, isSessionValid } from '../auth/session.js';
7
7
  import { ok, fail, warn, whatNext } from '../ui/theme.js';
8
8
 
9
9
  export function loginCommand(program: Command) {
@@ -34,7 +34,9 @@ export function loginCommand(program: Command) {
34
34
  password: opts.password,
35
35
  });
36
36
 
37
- console.log(ok(`Sesión guardada expira en 8 horas`));
37
+ const remainingMin = Math.round((session.expiresAt - Date.now()) / 60_000);
38
+ const remainingHr = (remainingMin / 60).toFixed(1);
39
+ console.log(ok(`Sesión guardada — expira en ${remainingHr}h`));
38
40
  if (session.userName) console.log(chalk.gray(` Usuario: ${session.userName}`));
39
41
  if (session.userId) console.log(chalk.gray(` ID: ${session.userId}`));
40
42
  whatNext();
@@ -56,7 +58,7 @@ export function loginCommand(program: Command) {
56
58
  .command('whoami')
57
59
  .description('Show current logged-in user')
58
60
  .action(async () => {
59
- const session = loadSession();
61
+ const session = await loadOrRefreshSession();
60
62
  if (!isSessionValid(session)) {
61
63
  console.log(chalk.red('Not logged in. Run: blackboard login'));
62
64
  process.exit(1);
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import { coursesCommand } from './commands/courses.js';
6
6
  import { apiDocsCommand } from './commands/api-docs.js';
7
7
  import { downloadCommand } from './commands/download.js';
8
8
  import { assignmentsCommand } from './commands/assignments.js';
9
- import { loadSession, isSessionValid } from './auth/session.js';
9
+ import { loadSession, loadOrRefreshSession, isSessionValid } from './auth/session.js';
10
10
  import { createClient } from './api/client.js';
11
11
  import { getMe, getSystemVersion } from './api/courses.js';
12
12
  import { BANNER, ok, fail, hint } from './ui/theme.js';
@@ -16,7 +16,7 @@ const program = new Command();
16
16
  program
17
17
  .name('blackboard')
18
18
  .description('CLI no oficial para UPC Aula Virtual (Blackboard Learn)')
19
- .version('1.0.0')
19
+ .version('1.0.7')
20
20
  .addHelpText('beforeAll', BANNER);
21
21
 
22
22
  // Auth commands
@@ -31,7 +31,7 @@ program
31
31
  .description('Estado de sesión y versión del servidor')
32
32
  .option('--json', 'Output raw JSON')
33
33
  .action(async (opts) => {
34
- const session = loadSession();
34
+ const session = await loadOrRefreshSession();
35
35
  const valid = isSessionValid(session);
36
36
 
37
37
  const sysVersion = await getSystemVersion(
@@ -75,7 +75,7 @@ program
75
75
  .option('-b, --body <json>', 'Cuerpo JSON para POST/PUT')
76
76
  .option('-q, --query <params>', 'Query params (ej: "limit=10&offset=0")')
77
77
  .action(async (method: string, apiPath: string, opts) => {
78
- const session = loadSession();
78
+ const session = await loadOrRefreshSession();
79
79
  if (!isSessionValid(session)) {
80
80
  console.error(JSON.stringify({ error: 'Not authenticated. Run: blackboard login' }));
81
81
  process.exit(1);
package/src/mcp/server.ts CHANGED
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { z } from 'zod';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
- import { loadSession, isSessionValid } from '../auth/session.js';
6
+ import { loadOrRefreshSession, isSessionValid } from '../auth/session.js';
7
7
  import { createClient } from '../api/client.js';
8
8
  import {
9
9
  getMe,
@@ -15,10 +15,18 @@ import {
15
15
  getGradeColumns,
16
16
  getSystemVersion,
17
17
  } from '../api/courses.js';
18
- import { listAssignments, listAttempts, submitAttempt, uploadFile } from '../api/assignments.js';
18
+ import { listAssignments, listAttempts, submitAttempt, uploadFile, getAttemptFiles } from '../api/assignments.js';
19
+ import {
20
+ getQuizQuestions,
21
+ saveQuizAnswer,
22
+ submitQuizAttempt,
23
+ getQuizColumnId,
24
+ parseQuizUrl,
25
+ type QuizQuestion,
26
+ } from '../api/quiz.js';
19
27
 
20
- function getClient() {
21
- const session = loadSession();
28
+ async function getClient() {
29
+ const session = await loadOrRefreshSession();
22
30
  if (!isSessionValid(session)) {
23
31
  throw new Error('Not authenticated. Ask the user to run: blackboard login');
24
32
  }
@@ -33,21 +41,21 @@ export async function startMcpServer() {
33
41
 
34
42
  // ── whoami ─────────────────────────────────────────────────────────────────
35
43
  server.registerTool('whoami', { description: 'Get the currently authenticated UPC student info' }, async () => {
36
- const { client } = getClient();
44
+ const { client } = await getClient();
37
45
  const me = await getMe(client);
38
46
  return { content: [{ type: 'text', text: JSON.stringify(me, null, 2) }] };
39
47
  });
40
48
 
41
49
  // ── system_version ─────────────────────────────────────────────────────────
42
50
  server.registerTool('system_version', { description: 'Get Blackboard Learn server version' }, async () => {
43
- const { client } = getClient();
51
+ const { client } = await getClient();
44
52
  const v = await getSystemVersion(client);
45
53
  return { content: [{ type: 'text', text: JSON.stringify(v, null, 2) }] };
46
54
  });
47
55
 
48
56
  // ── list_courses ────────────────────────────────────────────────────────────
49
57
  server.registerTool('list_courses', { description: 'List all enrolled courses for the current student' }, async () => {
50
- const { client, session } = getClient();
58
+ const { client, session } = await getClient();
51
59
  let userId = session.userId;
52
60
  if (!userId) { const me = await getMe(client); userId = me.id; }
53
61
  const data = await getMyCourses(client, userId!, { limit: 50 });
@@ -62,7 +70,7 @@ export async function startMcpServer() {
62
70
  inputSchema: { courseId: z.string().describe('Blackboard course ID like _529580_1') },
63
71
  },
64
72
  async ({ courseId }) => {
65
- const { client } = getClient();
73
+ const { client } = await getClient();
66
74
  const data = await getCourse(client, courseId);
67
75
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
68
76
  }
@@ -79,7 +87,7 @@ export async function startMcpServer() {
79
87
  },
80
88
  },
81
89
  async ({ courseId, parentId }) => {
82
- const { client } = getClient();
90
+ const { client } = await getClient();
83
91
  const data = await getCourseContents(client, courseId, parentId);
84
92
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
85
93
  }
@@ -93,7 +101,7 @@ export async function startMcpServer() {
93
101
  inputSchema: { courseId: z.string().describe('Blackboard course ID') },
94
102
  },
95
103
  async ({ courseId }) => {
96
- const { client } = getClient();
104
+ const { client } = await getClient();
97
105
  const data = await getCourseAnnouncements(client, courseId);
98
106
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
99
107
  }
@@ -107,7 +115,7 @@ export async function startMcpServer() {
107
115
  inputSchema: { courseId: z.string().describe('Blackboard course ID') },
108
116
  },
109
117
  async ({ courseId }) => {
110
- const { client } = getClient();
118
+ const { client } = await getClient();
111
119
  const data = await listAssignments(client, courseId);
112
120
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
113
121
  }
@@ -124,7 +132,7 @@ export async function startMcpServer() {
124
132
  },
125
133
  },
126
134
  async ({ courseId, columnId }) => {
127
- const { client } = getClient();
135
+ const { client } = await getClient();
128
136
  const data = await listAttempts(client, courseId, columnId);
129
137
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
130
138
  }
@@ -138,7 +146,7 @@ export async function startMcpServer() {
138
146
  inputSchema: { courseId: z.string().describe('Blackboard course ID') },
139
147
  },
140
148
  async ({ courseId }) => {
141
- const { client, session } = getClient();
149
+ const { client, session } = await getClient();
142
150
  let userId = session.userId;
143
151
  if (!userId) { const me = await getMe(client); userId = me.id; }
144
152
  const [columns, grades] = await Promise.all([
@@ -168,7 +176,7 @@ export async function startMcpServer() {
168
176
  },
169
177
  },
170
178
  async ({ courseId, contentId, attachmentId, filename, outputDir }) => {
171
- const { client } = getClient();
179
+ const { client } = await getClient();
172
180
 
173
181
  const url = attachmentId.startsWith('http')
174
182
  ? attachmentId
@@ -208,7 +216,7 @@ export async function startMcpServer() {
208
216
  },
209
217
  },
210
218
  async ({ courseId, contentId }) => {
211
- const { client } = getClient();
219
+ const { client } = await getClient();
212
220
 
213
221
  // Try standard REST attachments endpoint first (works for x-bb-file)
214
222
  try {
@@ -271,7 +279,7 @@ export async function startMcpServer() {
271
279
  },
272
280
  },
273
281
  async ({ url, filename, outputDir }) => {
274
- const { client } = getClient();
282
+ const { client } = await getClient();
275
283
  const r = await client.get(url, { responseType: 'arraybuffer', headers: { Accept: '*/*' } });
276
284
 
277
285
  const contentDisposition = r.headers['content-disposition'] as string | undefined;
@@ -308,7 +316,7 @@ export async function startMcpServer() {
308
316
  },
309
317
  },
310
318
  async ({ courseId, columnId, studentComments, studentSubmission }) => {
311
- const { client } = getClient();
319
+ const { client } = await getClient();
312
320
  const attempt = await submitAttempt(client, courseId, columnId, {
313
321
  studentComments,
314
322
  studentSubmission,
@@ -318,6 +326,228 @@ export async function startMcpServer() {
318
326
  }
319
327
  );
320
328
 
329
+ // ── get_quiz_questions ──────────────────────────────────────────────────────
330
+ server.registerTool(
331
+ 'get_quiz_questions',
332
+ {
333
+ description:
334
+ 'Fetch all questions and answer options from a Blackboard Ultra quiz attempt. ' +
335
+ 'Provide either a full quiz URL, or courseId + contentId + attemptId. ' +
336
+ 'Returns each question with its type, text, options, and current saved answer.',
337
+ inputSchema: {
338
+ url: z
339
+ .string()
340
+ .optional()
341
+ .describe(
342
+ 'Full Ultra quiz URL, e.g. https://aulavirtual.upc.edu.pe/ultra/stream/assessment/_69146765_1/overview/attempt/_94898825_1?courseId=_529533_1'
343
+ ),
344
+ courseId: z.string().optional().describe('Course ID (e.g. _529533_1) — required if url not given'),
345
+ contentId: z.string().optional().describe('Quiz content item ID (e.g. _69146765_1) — required if url not given'),
346
+ attemptId: z.string().optional().describe('Attempt ID (e.g. _94898825_1) — required if url not given'),
347
+ },
348
+ },
349
+ async ({ url, courseId, contentId, attemptId }) => {
350
+ const { client, session } = await getClient();
351
+
352
+ // Resolve IDs from URL or direct params
353
+ let resolvedCourseId = courseId;
354
+ let resolvedContentId = contentId;
355
+ let resolvedAttemptId = attemptId;
356
+
357
+ if (url) {
358
+ const parsed = parseQuizUrl(url);
359
+ resolvedCourseId = resolvedCourseId || parsed.courseId;
360
+ resolvedContentId = resolvedContentId || parsed.contentId;
361
+ resolvedAttemptId = resolvedAttemptId || parsed.attemptId;
362
+ }
363
+
364
+ if (!resolvedCourseId || !resolvedContentId || !resolvedAttemptId) {
365
+ throw new Error(
366
+ 'Provide either a full quiz URL or all three of: courseId, contentId, attemptId'
367
+ );
368
+ }
369
+
370
+ // Get columnId + attempt policy — reuse session already obtained above
371
+ const policy = await getQuizColumnId(client, resolvedCourseId, resolvedContentId, session.userId);
372
+
373
+ if (!policy.canAttempt) {
374
+ return {
375
+ content: [{
376
+ type: 'text',
377
+ text: JSON.stringify({
378
+ error: 'NO_ATTEMPTS_LEFT',
379
+ message: `No quedan intentos para este cuestionario. ${policy.attemptSummary}`,
380
+ policy,
381
+ }, null, 2),
382
+ }],
383
+ };
384
+ }
385
+
386
+ const info = await getQuizQuestions(client, resolvedCourseId, policy.columnId, resolvedAttemptId);
387
+ return {
388
+ content: [{
389
+ type: 'text',
390
+ text: JSON.stringify({ attemptPolicy: policy, ...info }, null, 2),
391
+ }],
392
+ };
393
+ }
394
+ );
395
+
396
+ // ── save_quiz_answer ────────────────────────────────────────────────────────
397
+ server.registerTool(
398
+ 'save_quiz_answer',
399
+ {
400
+ description:
401
+ 'Save a single answer for a quiz question (does NOT submit — use submit_quiz to finalize). ' +
402
+ 'question is the full question object from get_quiz_questions. ' +
403
+ 'answer: boolean for true/false, or 0-based index number for multiple-choice.',
404
+ inputSchema: {
405
+ courseId: z.string().describe('Course ID'),
406
+ attemptId: z.string().describe('Quiz attempt ID (e.g. _94898825_1)'),
407
+ question: z.string().describe('JSON string of the question object from get_quiz_questions'),
408
+ answer: z.union([z.boolean(), z.number()]).describe(
409
+ 'For true/false: true or false. For multiple-choice: 0-based index of selected option.'
410
+ ),
411
+ },
412
+ },
413
+ async ({ courseId, attemptId, question: questionJson, answer }) => {
414
+ const { client } = await getClient();
415
+ const question: QuizQuestion = JSON.parse(questionJson);
416
+ const result = await saveQuizAnswer(client, courseId, attemptId, question, answer as boolean | number);
417
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
418
+ }
419
+ );
420
+
421
+ // ── submit_quiz ─────────────────────────────────────────────────────────────
422
+ server.registerTool(
423
+ 'submit_quiz',
424
+ {
425
+ description:
426
+ 'Finalize and submit a quiz attempt. ALWAYS confirm with the user before calling this. ' +
427
+ 'All individual answers should be saved first via save_quiz_answer.',
428
+ inputSchema: {
429
+ courseId: z.string().describe('Course ID'),
430
+ attemptId: z.string().describe('Quiz attempt ID to submit'),
431
+ },
432
+ },
433
+ async ({ courseId, attemptId }) => {
434
+ const { client } = await getClient();
435
+ const result = await submitQuizAttempt(client, courseId, attemptId);
436
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
437
+ }
438
+ );
439
+
440
+ // ── get_assignment_feedback ─────────────────────────────────────────────────
441
+ server.registerTool(
442
+ 'get_assignment_feedback',
443
+ {
444
+ description:
445
+ 'Get professor feedback and scores for all assignments in a course. ' +
446
+ 'For each graded submission, shows score, instructor comments, and any feedback files attached by the professor.',
447
+ inputSchema: { courseId: z.string().describe('Blackboard course ID') },
448
+ },
449
+ async ({ courseId }) => {
450
+ const { client, session } = await getClient();
451
+
452
+ const assignments = await listAssignments(client, courseId);
453
+
454
+ const results = await Promise.all(
455
+ assignments.map(async (col) => {
456
+ try {
457
+ const attempts = await listAttempts(client, courseId, col.id);
458
+ if (!attempts.length) {
459
+ return { assignment: col.name, columnId: col.id, status: 'no_attempts' };
460
+ }
461
+
462
+ // Most recent attempt first
463
+ const latest = attempts.sort((a, b) =>
464
+ (b.attemptDate ?? b.modified ?? '').localeCompare(a.attemptDate ?? a.modified ?? '')
465
+ )[0];
466
+
467
+ // Try to get feedback files (professor may have attached annotated docs)
468
+ let feedbackFiles: any[] = [];
469
+ try {
470
+ feedbackFiles = await getAttemptFiles(client, courseId, col.id, latest.id);
471
+ } catch {}
472
+
473
+ return {
474
+ assignment: col.name,
475
+ columnId: col.id,
476
+ contentId: col.contentId,
477
+ due: col.grading?.due,
478
+ maxScore: col.score?.possible,
479
+ attempt: {
480
+ id: latest.id,
481
+ status: latest.status,
482
+ score: latest.score,
483
+ grade: latest.displayGrade?.text,
484
+ submittedAt: latest.attemptDate ?? latest.modified,
485
+ // Professor feedback — field name varies by BB version
486
+ instructorFeedback:
487
+ latest.text ?? latest.instructorFeedback ?? latest.feedback ?? null,
488
+ studentComments: latest.studentComments ?? null,
489
+ feedbackFiles: feedbackFiles.map((f) => ({
490
+ id: f.id,
491
+ name: f.name,
492
+ mimeType: f.mimeType,
493
+ size: f.size,
494
+ })),
495
+ },
496
+ };
497
+ } catch {
498
+ return { assignment: col.name, columnId: col.id, status: 'error_fetching' };
499
+ }
500
+ })
501
+ );
502
+
503
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
504
+ }
505
+ );
506
+
507
+ // ── download_feedback_file ───────────────────────────────────────────────────
508
+ server.registerTool(
509
+ 'download_feedback_file',
510
+ {
511
+ description:
512
+ '[EXPERIMENTAL] Download a feedback file that a professor attached to a graded attempt. ' +
513
+ 'Use the fileId from get_assignment_feedback → attempt.feedbackFiles. ' +
514
+ 'The download endpoint may not be available on all Blackboard versions.',
515
+ inputSchema: {
516
+ courseId: z.string().describe('Blackboard course ID'),
517
+ columnId: z.string().describe('Gradebook column (assignment) ID'),
518
+ attemptId: z.string().describe('Attempt ID from get_assignment_feedback'),
519
+ fileId: z.string().describe('File ID from get_assignment_feedback → attempt.feedbackFiles'),
520
+ filename: z.string().optional().describe('Filename to save as (defaults to the name from feedbackFiles)'),
521
+ outputDir: z.string().optional().describe('Directory to save the file (default: current working directory)'),
522
+ },
523
+ },
524
+ async ({ courseId, columnId, attemptId, fileId, filename, outputDir }) => {
525
+ const { client } = await getClient();
526
+
527
+ const url = `/learn/api/public/v2/courses/${courseId}/gradebook/columns/${columnId}/attempts/${attemptId}/files/${fileId}/download`;
528
+ const r = await client.get(url, { responseType: 'arraybuffer', headers: { Accept: '*/*' } });
529
+
530
+ const contentDisposition = r.headers['content-disposition'] as string | undefined;
531
+ const detectedName = contentDisposition
532
+ ? (contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/))?.[1]?.replace(/['"]/g, '').trim()
533
+ : undefined;
534
+ const finalName = filename ?? detectedName ?? `feedback_${fileId}`;
535
+
536
+ const dir = path.resolve(outputDir ?? process.cwd());
537
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
538
+ const dest = path.join(dir, finalName);
539
+ fs.writeFileSync(dest, Buffer.from(r.data));
540
+
541
+ const mimeType = (r.headers['content-type'] as string | undefined) ?? 'application/octet-stream';
542
+ return {
543
+ content: [{
544
+ type: 'text',
545
+ text: JSON.stringify({ saved: dest, size: r.data.byteLength, mimeType }, null, 2),
546
+ }],
547
+ };
548
+ }
549
+ );
550
+
321
551
  // ── raw_api ─────────────────────────────────────────────────────────────────
322
552
  server.registerTool(
323
553
  'raw_api',
@@ -331,7 +561,7 @@ export async function startMcpServer() {
331
561
  },
332
562
  },
333
563
  async ({ method, path, query, body }) => {
334
- const { client } = getClient();
564
+ const { client } = await getClient();
335
565
  const params = query ? Object.fromEntries(new URLSearchParams(query)) : undefined;
336
566
  const data = body ? JSON.parse(body) : undefined;
337
567
  const r = await client.request({ method: method.toLowerCase() as any, url: path, params, data });
package/tsconfig.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "compilerOptions": {
3
3
  "target": "ES2022",
4
4
  "module": "commonjs",
5
- "lib": ["ES2022"],
5
+ "lib": ["ES2022", "DOM"],
6
6
  "outDir": "dist",
7
7
  "rootDir": "src",
8
8
  "strict": true,