blackboard-upc 1.0.7 → 1.0.9
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 +23 -0
- package/CLAUDE.md +8 -0
- package/package.json +1 -1
- package/src/api/quiz.ts +38 -5
- package/src/auth/login.ts +42 -4
- package/src/auth/session.ts +12 -1
- package/src/commands/login.ts +28 -7
- package/src/index.ts +18 -6
- package/src/mcp/server.ts +29 -4
- package/.mcp.json +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes to `blackboard-upc` will be documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.0.9] — 2026-04-24
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **`whoami` y `status` mostraban `unknown`** — el extractor leía `userData.userName`, campo que la API de UPC no devuelve. Ahora se arma desde `name.given + name.family` con fallback a `studentId`
|
|
11
|
+
- **`logout` no permitía cambiar de cuenta** — solo borraba `session.json` pero no el browser profile de Playwright (`~/.blackboard-cli/browser-profile`), donde viven las cookies de Microsoft SSO. El siguiente `login` se auto-autenticaba con la misma cuenta. Ahora `logout` borra también el profile
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `blackboard logout --keep-profile` — conserva las cookies SSO (útil para renovar sesión de la misma cuenta sin re-ingresar credenciales)
|
|
15
|
+
- **Self-heal de sesiones viejas** — `whoami` y `status` detectan `userName: null` en sesiones guardadas con versiones previas y rellenan el nombre llamando a `/users/me` una sola vez (sin necesidad de re-login)
|
|
16
|
+
- `resolveDisplayName()` helper en `src/auth/login.ts` — centraliza la lógica de nombre desde la respuesta de `/users/me`
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## [1.0.8] — 2026-04-19
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Soporte para preguntas `fimb`** (fill-in-multiple-blanks) en `get_quiz_questions`, `save_quiz_answer` y el tipo `QuizQuestion`:
|
|
24
|
+
- `QuizQuestion.blanks` — array con los nombres de los blanks (ej. `["BLANK-1", "BLANK-2"]`)
|
|
25
|
+
- `QuizQuestion.currentAnswer` — para fimb, devuelve `Record<string, string|null>` con el valor actual de cada blank
|
|
26
|
+
- `save_quiz_answer` ahora acepta un JSON string con el mapa `{blankName: value}` (ej. `'{"BLANK-1":"1438.62","BLANK-2":"140.62"}'`)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
7
30
|
## [1.0.7] — 2026-04-12
|
|
8
31
|
|
|
9
32
|
### 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
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
|
-
|
|
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/auth/login.ts
CHANGED
|
@@ -43,6 +43,20 @@ function extractBbRouterExpiry(cookies: Cookie[]): number {
|
|
|
43
43
|
return Date.now() + SESSION_TTL_FALLBACK_MS;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// UPC's /users/me omits `userName` at the root. The display name must be
|
|
47
|
+
// assembled from `name.given` + `name.family`, falling back to `studentId`.
|
|
48
|
+
export function resolveDisplayName(userData: any): string | undefined {
|
|
49
|
+
if (!userData) return undefined;
|
|
50
|
+
if (typeof userData.userName === 'string' && userData.userName.trim()) return userData.userName.trim();
|
|
51
|
+
const full = [userData?.name?.given, userData?.name?.family].filter(Boolean).join(' ').trim();
|
|
52
|
+
if (full) return full;
|
|
53
|
+
if (userData?.name?.preferredDisplayName && typeof userData.name.preferredDisplayName === 'string') {
|
|
54
|
+
const pdn = userData.name.preferredDisplayName.trim();
|
|
55
|
+
if (pdn && pdn !== 'GivenName' && pdn !== 'FamilyName') return pdn;
|
|
56
|
+
}
|
|
57
|
+
return userData.studentId || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
function extractXsrf(cookies: Cookie[]): string {
|
|
47
61
|
const bb = cookies.find(c => c.name === 'BbRouter');
|
|
48
62
|
if (bb) {
|
|
@@ -158,16 +172,18 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
|
|
|
158
172
|
if (resp.ok()) userData = await resp.json();
|
|
159
173
|
} catch {}
|
|
160
174
|
|
|
175
|
+
const displayName = resolveDisplayName(userData);
|
|
176
|
+
|
|
161
177
|
const session: Session = {
|
|
162
178
|
cookies,
|
|
163
179
|
xsrfToken: nonce,
|
|
164
180
|
userId: userData?.id,
|
|
165
|
-
userName:
|
|
181
|
+
userName: displayName,
|
|
166
182
|
expiresAt: extractBbRouterExpiry(cookies),
|
|
167
183
|
};
|
|
168
184
|
|
|
169
185
|
saveSession(session);
|
|
170
|
-
console.log(`✓ Logged in as ${
|
|
186
|
+
console.log(`✓ Logged in as ${displayName || 'unknown'}`);
|
|
171
187
|
|
|
172
188
|
return session;
|
|
173
189
|
} finally {
|
|
@@ -215,11 +231,33 @@ export async function silentRelogin(previousSession?: Session | null): Promise<S
|
|
|
215
231
|
throw new SilentLoginFailed('Redirect succeeded but session cookies are missing');
|
|
216
232
|
}
|
|
217
233
|
|
|
234
|
+
let userId = previousSession?.userId;
|
|
235
|
+
let userName = previousSession?.userName;
|
|
236
|
+
|
|
237
|
+
// Self-heal: older sessions were stored with userName=null because the
|
|
238
|
+
// old extractor looked at the wrong field. Refetch once if it's missing.
|
|
239
|
+
if (!userName || !userId) {
|
|
240
|
+
try {
|
|
241
|
+
const cookieStrForApi = cookies
|
|
242
|
+
.filter(c => c.domain.includes('aulavirtual.upc.edu.pe'))
|
|
243
|
+
.map(c => `${c.name}=${c.value}`)
|
|
244
|
+
.join('; ');
|
|
245
|
+
const resp = await page.request.get(`${BASE_URL}/learn/api/public/v1/users/me`, {
|
|
246
|
+
headers: { Accept: 'application/json', Cookie: cookieStrForApi },
|
|
247
|
+
});
|
|
248
|
+
if (resp.ok()) {
|
|
249
|
+
const me = await resp.json();
|
|
250
|
+
userId = userId ?? me?.id;
|
|
251
|
+
userName = userName ?? resolveDisplayName(me);
|
|
252
|
+
}
|
|
253
|
+
} catch {}
|
|
254
|
+
}
|
|
255
|
+
|
|
218
256
|
const session: Session = {
|
|
219
257
|
cookies,
|
|
220
258
|
xsrfToken: extractXsrf(cookies),
|
|
221
|
-
userId
|
|
222
|
-
userName
|
|
259
|
+
userId,
|
|
260
|
+
userName,
|
|
223
261
|
expiresAt: extractBbRouterExpiry(cookies),
|
|
224
262
|
};
|
|
225
263
|
|
package/src/auth/session.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Session } from '../types/index.js';
|
|
|
5
5
|
|
|
6
6
|
const SESSION_DIR = path.join(os.homedir(), '.blackboard-cli');
|
|
7
7
|
const SESSION_FILE = path.join(SESSION_DIR, 'session.json');
|
|
8
|
+
const PROFILE_DIR = path.join(SESSION_DIR, 'browser-profile');
|
|
8
9
|
|
|
9
10
|
export function saveSession(session: Session): void {
|
|
10
11
|
if (!fs.existsSync(SESSION_DIR)) {
|
|
@@ -27,10 +28,20 @@ export function loadSession(): Session | null {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export function clearSession(): void {
|
|
31
|
+
export function clearSession(opts: { keepProfile?: boolean } = {}): void {
|
|
31
32
|
try {
|
|
32
33
|
if (fs.existsSync(SESSION_FILE)) fs.unlinkSync(SESSION_FILE);
|
|
33
34
|
} catch {}
|
|
35
|
+
if (!opts.keepProfile) clearBrowserProfile();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The browser profile holds Microsoft SSO cookies. Without clearing it,
|
|
39
|
+
// the next `login` silently re-authenticates with the same account and
|
|
40
|
+
// switching users becomes impossible.
|
|
41
|
+
export function clearBrowserProfile(): void {
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(PROFILE_DIR)) fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
|
|
44
|
+
} catch {}
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export function isSessionValid(session: Session | null): boolean {
|
package/src/commands/login.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { Command } from 'commander';
|
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { login } from '../auth/login.js';
|
|
6
|
-
import { loadSession, loadOrRefreshSession, clearSession, isSessionValid } from '../auth/session.js';
|
|
5
|
+
import { login, resolveDisplayName } from '../auth/login.js';
|
|
6
|
+
import { loadSession, loadOrRefreshSession, saveSession, clearSession, isSessionValid } from '../auth/session.js';
|
|
7
|
+
import { createClient } from '../api/client.js';
|
|
8
|
+
import { getMe } from '../api/courses.js';
|
|
7
9
|
import { ok, fail, warn, whatNext } from '../ui/theme.js';
|
|
8
10
|
|
|
9
11
|
export function loginCommand(program: Command) {
|
|
@@ -48,21 +50,40 @@ export function loginCommand(program: Command) {
|
|
|
48
50
|
|
|
49
51
|
program
|
|
50
52
|
.command('logout')
|
|
51
|
-
.description('
|
|
52
|
-
.
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
.description('Borra la sesión y las cookies SSO para poder cambiar de cuenta')
|
|
54
|
+
.option('--keep-profile', 'Conservar las cookies SSO del navegador (no permite cambiar de cuenta)')
|
|
55
|
+
.action((opts) => {
|
|
56
|
+
clearSession({ keepProfile: !!opts.keepProfile });
|
|
57
|
+
if (opts.keepProfile) {
|
|
58
|
+
console.log(chalk.green('✓ Sesión borrada') + chalk.gray(' (profile SSO conservado)'));
|
|
59
|
+
} else {
|
|
60
|
+
console.log(chalk.green('✓ Sesión y profile SSO borrados'));
|
|
61
|
+
console.log(chalk.gray(' El próximo `blackboard login` te pedirá credenciales de nuevo.'));
|
|
62
|
+
}
|
|
55
63
|
});
|
|
56
64
|
|
|
57
65
|
program
|
|
58
66
|
.command('whoami')
|
|
59
67
|
.description('Show current logged-in user')
|
|
60
68
|
.action(async () => {
|
|
61
|
-
|
|
69
|
+
let session = await loadOrRefreshSession();
|
|
62
70
|
if (!isSessionValid(session)) {
|
|
63
71
|
console.log(chalk.red('Not logged in. Run: blackboard login'));
|
|
64
72
|
process.exit(1);
|
|
65
73
|
}
|
|
74
|
+
|
|
75
|
+
// Self-heal old sessions that were stored with userName=null.
|
|
76
|
+
if (!session!.userName) {
|
|
77
|
+
try {
|
|
78
|
+
const me = await getMe(createClient(session!));
|
|
79
|
+
const name = resolveDisplayName(me);
|
|
80
|
+
if (name) {
|
|
81
|
+
session = { ...session!, userId: session!.userId ?? me?.id, userName: name };
|
|
82
|
+
saveSession(session!);
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
console.log(chalk.green(`Logged in as: ${chalk.bold(session!.userName || session!.userId || 'unknown')}`));
|
|
67
88
|
const remaining = Math.round((session!.expiresAt - Date.now()) / 60_000);
|
|
68
89
|
console.log(chalk.gray(`Session expires in: ${remaining} minutes`));
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,10 @@ 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, loadOrRefreshSession, isSessionValid } from './auth/session.js';
|
|
9
|
+
import { loadSession, loadOrRefreshSession, saveSession, isSessionValid } from './auth/session.js';
|
|
10
10
|
import { createClient } from './api/client.js';
|
|
11
11
|
import { getMe, getSystemVersion } from './api/courses.js';
|
|
12
|
+
import { resolveDisplayName } from './auth/login.js';
|
|
12
13
|
import { BANNER, ok, fail, hint } from './ui/theme.js';
|
|
13
14
|
|
|
14
15
|
const program = new Command();
|
|
@@ -16,7 +17,7 @@ const program = new Command();
|
|
|
16
17
|
program
|
|
17
18
|
.name('blackboard')
|
|
18
19
|
.description('CLI no oficial para UPC Aula Virtual (Blackboard Learn)')
|
|
19
|
-
.version('1.0.
|
|
20
|
+
.version('1.0.9')
|
|
20
21
|
.addHelpText('beforeAll', BANNER);
|
|
21
22
|
|
|
22
23
|
// Auth commands
|
|
@@ -31,12 +32,23 @@ program
|
|
|
31
32
|
.description('Estado de sesión y versión del servidor')
|
|
32
33
|
.option('--json', 'Output raw JSON')
|
|
33
34
|
.action(async (opts) => {
|
|
34
|
-
|
|
35
|
+
let session = await loadOrRefreshSession();
|
|
35
36
|
const valid = isSessionValid(session);
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const client = createClient(session ?? { cookies: [], xsrfToken: '', expiresAt: 0 });
|
|
39
|
+
const sysVersion = await getSystemVersion(client).catch(() => null);
|
|
40
|
+
|
|
41
|
+
// Self-heal old sessions with userName=null.
|
|
42
|
+
if (valid && !session!.userName) {
|
|
43
|
+
try {
|
|
44
|
+
const me = await getMe(client);
|
|
45
|
+
const name = resolveDisplayName(me);
|
|
46
|
+
if (name) {
|
|
47
|
+
session = { ...session!, userId: session!.userId ?? me?.id, userName: name };
|
|
48
|
+
saveSession(session!);
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
40
52
|
|
|
41
53
|
const result = {
|
|
42
54
|
loggedIn: valid,
|
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
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
);
|