blackboard-upc 1.0.7 → 1.0.8

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,16 @@ All notable changes to `blackboard-upc` will be documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.0.8] — 2026-04-19
8
+
9
+ ### Added
10
+ - **Soporte para preguntas `fimb`** (fill-in-multiple-blanks) en `get_quiz_questions`, `save_quiz_answer` y el tipo `QuizQuestion`:
11
+ - `QuizQuestion.blanks` — array con los nombres de los blanks (ej. `["BLANK-1", "BLANK-2"]`)
12
+ - `QuizQuestion.currentAnswer` — para fimb, devuelve `Record<string, string|null>` con el valor actual de cada blank
13
+ - `save_quiz_answer` ahora acepta un JSON string con el mapa `{blankName: value}` (ej. `'{"BLANK-1":"1438.62","BLANK-2":"140.62"}'`)
14
+
15
+ ---
16
+
7
17
  ## [1.0.7] — 2026-04-12
8
18
 
9
19
  ### Added
package/CLAUDE.md CHANGED
@@ -33,6 +33,14 @@ If you get `Not authenticated`, ask the user to run `blackboard login`.
33
33
  4. submit_quiz (confirm first!) → finalize and submit the attempt
34
34
  ```
35
35
 
36
+ Supported question types in `save_quiz_answer`:
37
+
38
+ | `question.type` | `answer` format |
39
+ |-------------------|--------------------------------------------------------------------------------|
40
+ | `eitherOr` | boolean (`true` = Verdadero, `false` = Falso) |
41
+ | `multipleanswer` | number — 0-based index of the chosen option |
42
+ | `fimb` | JSON string `'{"BLANK-1":"value1","BLANK-2":"value2"}'` — read names from `question.blanks` |
43
+
36
44
  ### Feedback workflow
37
45
 
38
46
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blackboard-upc",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
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/api/quiz.ts CHANGED
@@ -25,7 +25,7 @@ export interface QuizOption {
25
25
  index: number;
26
26
  }
27
27
 
28
- export type QuizQuestionType = 'eitherOr' | 'multipleanswer' | 'presentation' | string;
28
+ export type QuizQuestionType = 'eitherOr' | 'multipleanswer' | 'fimb' | 'presentation' | string;
29
29
 
30
30
  export interface QuizQuestion {
31
31
  /** Question attempt ID — used as the URL segment for saving answers */
@@ -34,18 +34,21 @@ export interface QuizQuestion {
34
34
  questionId: string;
35
35
  /** Position within the quiz (1-based visible number) */
36
36
  position: number;
37
- /** 'eitherOr' = true/false, 'multipleanswer' = MC, 'presentation' = text only */
37
+ /** 'eitherOr' = true/false, 'multipleanswer' = MC, 'fimb' = fill-in-multiple-blanks, 'presentation' = text only */
38
38
  type: QuizQuestionType;
39
39
  /** Plain text of the question (HTML stripped) */
40
40
  text: string;
41
41
  points: number;
42
42
  /** Answer options (present for eitherOr and multipleanswer) */
43
43
  options?: QuizOption[];
44
+ /** Blank names in order (present for fimb), e.g. ['BLANK-1', 'BLANK-2'] */
45
+ blanks?: string[];
44
46
  /** Currently saved answer:
45
47
  * - eitherOr: true | false | null
46
48
  * - multipleanswer: boolean[] (one per option, in options order)
49
+ * - fimb: Record<string, string|null> (one per blank)
47
50
  */
48
- currentAnswer?: boolean | boolean[] | null;
51
+ currentAnswer?: boolean | boolean[] | Record<string, string | null> | null;
49
52
  /** Raw question object from API (needed when saving eitherOr answers) */
50
53
  _raw: any;
51
54
  }
@@ -76,6 +79,8 @@ function parseQuestionAttempt(qa: any, idx: number): QuizQuestion {
76
79
  const text = stripHtml(q.questionText?.rawText || q.questionText?.displayText || '');
77
80
 
78
81
  let options: QuizOption[] | undefined;
82
+ let blanks: string[] | undefined;
83
+ let currentAnswer: QuizQuestion['currentAnswer'] = qa.givenAnswer ?? null;
79
84
 
80
85
  if (qa.questionType === 'eitherOr') {
81
86
  // True/False — always two options
@@ -89,6 +94,10 @@ function parseQuestionAttempt(qa: any, idx: number): QuizQuestion {
89
94
  text: stripHtml(a.answerText?.rawText || a.answerText?.displayText || ''),
90
95
  index: i,
91
96
  }));
97
+ } else if (qa.questionType === 'fimb' && qa.givenAnswers && typeof qa.givenAnswers === 'object') {
98
+ // Fill-in-multiple-blanks — blank names come from givenAnswers keys (e.g. BLANK-1, BLANK-2)
99
+ blanks = Object.keys(qa.givenAnswers);
100
+ currentAnswer = qa.givenAnswers as Record<string, string | null>;
92
101
  }
93
102
 
94
103
  return {
@@ -99,7 +108,8 @@ function parseQuestionAttempt(qa: any, idx: number): QuizQuestion {
99
108
  text,
100
109
  points: q.points ?? 0,
101
110
  options,
102
- currentAnswer: qa.givenAnswer ?? null,
111
+ blanks,
112
+ currentAnswer,
103
113
  _raw: qa,
104
114
  };
105
115
  }
@@ -165,13 +175,14 @@ export async function getQuizQuestions(
165
175
  * - eitherOr: boolean (true = Verdadero, false = Falso)
166
176
  * - multipleanswer: number (0-based index of the selected option)
167
177
  * OR boolean[] (one per option)
178
+ * - fimb: Record<string, string> (one value per blank name, e.g. { "BLANK-1": "1438.62", "BLANK-2": "140.62" })
168
179
  */
169
180
  export async function saveQuizAnswer(
170
181
  client: AxiosInstance,
171
182
  courseId: string,
172
183
  attemptId: string,
173
184
  question: QuizQuestion,
174
- answer: boolean | number | boolean[]
185
+ answer: boolean | number | boolean[] | Record<string, string>
175
186
  ): Promise<any> {
176
187
  const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}/assessment/answers/${question.questionAttemptId}`;
177
188
 
@@ -203,6 +214,28 @@ export async function saveQuizAnswer(
203
214
  order: question._raw.order || [],
204
215
  question: question._raw.question,
205
216
  };
217
+ } else if (question.type === 'fimb') {
218
+ if (typeof answer !== 'object' || Array.isArray(answer) || answer === null) {
219
+ throw new Error(
220
+ `fimb answers must be a Record<string, string> mapping blank name to value. ` +
221
+ `Expected blanks: ${question.blanks?.join(', ') || '(unknown)'}`
222
+ );
223
+ }
224
+
225
+ // Build givenAnswers using the question's known blank names — preserves order and
226
+ // ensures any blank not provided ends up as null (consistent with Blackboard behavior).
227
+ const blanks = question.blanks ?? Object.keys(answer);
228
+ const givenAnswers: Record<string, string | null> = {};
229
+ for (const name of blanks) {
230
+ const val = (answer as Record<string, string>)[name];
231
+ givenAnswers[name] = val !== undefined ? String(val) : null;
232
+ }
233
+
234
+ body = {
235
+ questionType: 'fimb',
236
+ givenAnswers,
237
+ question: question._raw.question,
238
+ };
206
239
  } else {
207
240
  throw new Error(`Unsupported question type: ${question.type}`);
208
241
  }
package/src/mcp/server.ts CHANGED
@@ -400,20 +400,45 @@ export async function startMcpServer() {
400
400
  description:
401
401
  'Save a single answer for a quiz question (does NOT submit — use submit_quiz to finalize). ' +
402
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.',
403
+ 'answer format depends on question.type:\n' +
404
+ ' - eitherOr (true/false): boolean (true = Verdadero)\n' +
405
+ ' - multipleanswer (MC): number (0-based index of the chosen option)\n' +
406
+ ' - fimb (fill-in-multi-blanks): JSON string of an object mapping blank names to values, ' +
407
+ 'e.g. \'{"BLANK-1":"1438.62","BLANK-2":"140.62"}\' (read blank names from question.blanks)',
404
408
  inputSchema: {
405
409
  courseId: z.string().describe('Course ID'),
406
410
  attemptId: z.string().describe('Quiz attempt ID (e.g. _94898825_1)'),
407
411
  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.'
412
+ answer: z.union([z.boolean(), z.number(), z.string()]).describe(
413
+ 'eitherOr: true/false. multipleanswer: 0-based index. ' +
414
+ 'fimb: JSON string of {blankName: value} (e.g. \'{"BLANK-1":"1438.62"}\').'
410
415
  ),
411
416
  },
412
417
  },
413
418
  async ({ courseId, attemptId, question: questionJson, answer }) => {
414
419
  const { client } = await getClient();
415
420
  const question: QuizQuestion = JSON.parse(questionJson);
416
- const result = await saveQuizAnswer(client, courseId, attemptId, question, answer as boolean | number);
421
+
422
+ // For fimb, the MCP transport gives us a JSON string — parse it into the Record<string, string>
423
+ // saveQuizAnswer expects. boolean / number pass through as-is.
424
+ let parsedAnswer: boolean | number | Record<string, string>;
425
+ if (typeof answer === 'string') {
426
+ try {
427
+ const obj = JSON.parse(answer);
428
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
429
+ throw new Error('fimb answer string must parse to a JSON object');
430
+ }
431
+ parsedAnswer = obj as Record<string, string>;
432
+ } catch (e: any) {
433
+ throw new Error(
434
+ `Invalid fimb answer: expected a JSON object string like '{"BLANK-1":"value"}'. ${e.message}`
435
+ );
436
+ }
437
+ } else {
438
+ parsedAnswer = answer;
439
+ }
440
+
441
+ const result = await saveQuizAnswer(client, courseId, attemptId, question, parsedAnswer);
417
442
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
418
443
  }
419
444
  );
package/.mcp.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "mcpServers": {
3
- "blackboard": {
4
- "command": "npx",
5
- "args": ["blackboard-upc", "mcp"]
6
- }
7
- }
8
- }