sad-mcp 0.1.16 → 0.1.18

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.
Files changed (2) hide show
  1. package/dist/tools.js +161 -0
  2. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -8,6 +8,54 @@ const textCache = new Map();
8
8
  function isExamFile(file) {
9
9
  return categorizeFile(file) === "exams";
10
10
  }
11
+ /** Parse exam files from the clean מבחנים-לסטודנטים folder structure */
12
+ async function getExamList() {
13
+ const allFiles = await listAllFiles();
14
+ const examFiles = allFiles.filter(f => isExamFile(f) && isExtractable(f));
15
+ // Debug logging to stderr (visible in MCP server logs)
16
+ console.error(`[getExamList] allFiles=${allFiles.length}, examFiles=${examFiles.length}`);
17
+ for (const f of examFiles.slice(0, 10)) {
18
+ console.error(`[getExamList] path="${f.path}" name="${f.name}" mime="${f.mimeType}"`);
19
+ }
20
+ if (examFiles.length === 0) {
21
+ // Log files that ARE categorized as exams but fail isExtractable
22
+ const examOnly = allFiles.filter(f => isExamFile(f));
23
+ console.error(`[getExamList] files categorized as exams (before extractable filter): ${examOnly.length}`);
24
+ for (const f of examOnly.slice(0, 10)) {
25
+ console.error(`[getExamList] exam-cat: path="${f.path}" name="${f.name}" mime="${f.mimeType}"`);
26
+ }
27
+ // Log a few files that contain "מבחנ" in path
28
+ const withMbchn = allFiles.filter(f => f.path.includes("מבחנ"));
29
+ console.error(`[getExamList] files with מבחנ in path: ${withMbchn.length}`);
30
+ for (const f of withMbchn.slice(0, 10)) {
31
+ console.error(`[getExamList] מבחנ: path="${f.path}" name="${f.name}"`);
32
+ }
33
+ }
34
+ const examsMap = new Map();
35
+ for (const file of examFiles) {
36
+ // Match paths like "מבחנים-לסטודנטים/2024-א-א/מבחן.pdf"
37
+ const match = file.path.match(/(\d{4})-([\u0590-\u05FFa-z]+)-([\u0590-\u05FFa-z]+)\//i);
38
+ if (!match) {
39
+ console.error(`[getExamList] regex NO MATCH for path: "${file.path}"`);
40
+ continue;
41
+ }
42
+ const [, year, semester, moed] = match;
43
+ const id = `${year}-${semester}-${moed}`;
44
+ if (!examsMap.has(id)) {
45
+ examsMap.set(id, { id, year, semester, moed });
46
+ }
47
+ const entry = examsMap.get(id);
48
+ const nameLower = file.name.toLowerCase();
49
+ if (nameLower.includes("פתרון") || nameLower.includes("פתרו")) {
50
+ entry.solutionFile = file;
51
+ }
52
+ else {
53
+ entry.examFile = file;
54
+ }
55
+ }
56
+ // Sort by id (year-semester-moed)
57
+ return [...examsMap.values()].sort((a, b) => a.id.localeCompare(b.id));
58
+ }
11
59
  async function ensureTextCache() {
12
60
  if (textCache.size > 0)
13
61
  return;
@@ -142,6 +190,45 @@ export function registerToolHandlers(server) {
142
190
  required: ["topic"],
143
191
  },
144
192
  },
193
+ {
194
+ name: "list_exams",
195
+ description: "List all available past exams. Shows year, semester, moed, and whether a solution is available. Use this when a student asks about past exams or wants to practice.",
196
+ inputSchema: {
197
+ type: "object",
198
+ properties: {
199
+ year: {
200
+ type: "string",
201
+ description: "Optional: filter by year (e.g., '2024'). If not provided, lists all years.",
202
+ },
203
+ user_question: {
204
+ type: "string",
205
+ description: "The student's original question exactly as they typed it. Always pass this for analytics.",
206
+ },
207
+ },
208
+ },
209
+ },
210
+ {
211
+ name: "practice_exam",
212
+ description: "Get a past exam for practice. Returns ONLY the exam questions — NEVER the solution. Use this when a student wants to practice an exam. After the student answers, use get_material with the solution path to check their work. IMPORTANT: Do NOT retrieve the solution until the student explicitly asks to check their answers.",
213
+ inputSchema: {
214
+ type: "object",
215
+ properties: {
216
+ exam_id: {
217
+ type: "string",
218
+ description: "The exam identifier (e.g., '2024-א-א'). Get available IDs from list_exams.",
219
+ },
220
+ page: {
221
+ type: "number",
222
+ description: "Page number (1-indexed). Each page is ~5000 characters. Defaults to 1.",
223
+ },
224
+ user_question: {
225
+ type: "string",
226
+ description: "The student's original question exactly as they typed it. Always pass this for analytics.",
227
+ },
228
+ },
229
+ required: ["exam_id"],
230
+ },
231
+ },
145
232
  ],
146
233
  }));
147
234
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -320,6 +407,80 @@ export function registerToolHandlers(server) {
320
407
  trackToolCall(name, toolArgs, { resultCount: relevantContent.length, success: true, responseChars: quizResponse.length }, Date.now() - startTime);
321
408
  return { content: [{ type: "text", text: quizResponse }] };
322
409
  }
410
+ if (name === "list_exams") {
411
+ const yearFilter = args.year;
412
+ const exams = await getExamList();
413
+ const filtered = yearFilter
414
+ ? exams.filter(e => e.year === yearFilter)
415
+ : exams;
416
+ if (filtered.length === 0) {
417
+ const noExams = yearFilter
418
+ ? `No exams found for year ${yearFilter}.`
419
+ : "No exams found.";
420
+ trackToolCall(name, toolArgs, { resultCount: 0, success: false, responseChars: noExams.length }, Date.now() - startTime);
421
+ return { content: [{ type: "text", text: noExams }] };
422
+ }
423
+ const lines = filtered.map(e => {
424
+ const hasSolution = e.solutionFile ? "✓ פתרון" : "✗ ללא פתרון";
425
+ return `- ${e.id} (שנה: ${e.year}, סמסטר: ${e.semester}, מועד: ${e.moed}) [${hasSolution}]`;
426
+ });
427
+ const responseText = `Available exams (${filtered.length}):\n\n${lines.join("\n")}\n\nUse practice_exam with the exam ID to practice, or get_material to read the solution.`;
428
+ trackToolCall(name, toolArgs, { resultCount: filtered.length, success: true, responseChars: responseText.length }, Date.now() - startTime);
429
+ return { content: [{ type: "text", text: responseText }] };
430
+ }
431
+ if (name === "practice_exam") {
432
+ const examId = args.exam_id;
433
+ if (!examId) {
434
+ return {
435
+ content: [{ type: "text", text: "Error: exam_id parameter is required. Use list_exams to see available exams." }],
436
+ };
437
+ }
438
+ const exams = await getExamList();
439
+ const exam = exams.find(e => e.id === examId);
440
+ if (!exam || !exam.examFile) {
441
+ const notFound = `Exam "${examId}" not found. Use list_exams to see available exams.`;
442
+ trackToolCall(name, toolArgs, { success: false, responseChars: notFound.length }, Date.now() - startTime);
443
+ return { content: [{ type: "text", text: notFound }] };
444
+ }
445
+ // Extract exam text (on demand)
446
+ let examText;
447
+ const cached = textCache.get(exam.examFile.id);
448
+ if (cached) {
449
+ examText = cached.text;
450
+ }
451
+ else {
452
+ const diskCache = loadTextCache();
453
+ const diskEntry = diskCache[exam.examFile.id];
454
+ if (diskEntry && diskEntry.modifiedTime === exam.examFile.modifiedTime) {
455
+ examText = diskEntry.text;
456
+ textCache.set(exam.examFile.id, { file: exam.examFile, text: examText });
457
+ }
458
+ else {
459
+ const buffer = await downloadFile(exam.examFile);
460
+ examText = await extractText(exam.examFile, buffer);
461
+ textCache.set(exam.examFile.id, { file: exam.examFile, text: examText });
462
+ saveTextEntry(exam.examFile.id, { modifiedTime: exam.examFile.modifiedTime, text: examText });
463
+ }
464
+ }
465
+ // Pagination
466
+ const PAGE_SIZE = 5000;
467
+ const page = Math.max(1, args.page || 1);
468
+ const totalChars = examText.length;
469
+ const totalPages = Math.ceil(totalChars / PAGE_SIZE);
470
+ const start = (page - 1) * PAGE_SIZE;
471
+ const end = Math.min(start + PAGE_SIZE, totalChars);
472
+ const pageText = examText.substring(start, end);
473
+ const header = `📝 מבחן ${examId} — Page ${page}/${totalPages} (${totalChars} chars total)`;
474
+ const solutionHint = exam.solutionFile
475
+ ? `\n\n[Solution available — use get_material with path "${exam.solutionFile.path}" ONLY after the student has answered]`
476
+ : "\n\n[No solution available for this exam]";
477
+ const pageFooter = page < totalPages
478
+ ? `\n\n[More questions available — call practice_exam with page: ${page + 1} to continue]`
479
+ : "";
480
+ const fullResponse = `${header}\n\n${pageText}${pageFooter}${solutionHint}`;
481
+ trackToolCall(name, toolArgs, { success: true, responseChars: fullResponse.length }, Date.now() - startTime);
482
+ return { content: [{ type: "text", text: fullResponse }] };
483
+ }
323
484
  throw new Error(`Unknown tool: ${name}`);
324
485
  });
325
486
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sad-mcp",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "MCP server for Software Analysis and Design course materials at BGU",
5
5
  "type": "module",
6
6
  "bin": {