Skip to content

Commit f19d436

Browse files
fix(mcp): fix tool bugs and add missing CRUD operations
- Add tenant_id to lms_create_lesson and lms_create_exercise inserts - Fix lms_create_exam: exam_date was NOT NULL in DB but optional in tool — add migration to make it nullable - Fix lms_get_exam: order questions by question_id for deterministic output - Fix lms_upsert_lesson_ai_task: validate at least one field provided - Fix lms_get_lesson_ai_task: use maybeSingle() to distinguish DB errors from missing row - Fix lms_delete_exam_question: correct idempotentHint to false - Fix lms_add_exam_question and lms_create_exam: validate multiple_choice/true_false questions have options - Fix lms_list_templates: scope to system templates + own templates (was returning all tenants' data) - Add lms_delete_lesson, lms_delete_exam, lms_delete_exercise tools - Add lms_get_lesson_ai_task tool - Fix changed_by NOT NULL violation when MCP service role updates lessons/exams Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c68a29f commit f19d436

5 files changed

Lines changed: 360 additions & 2 deletions

File tree

mcp-server/src/tools/exams.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export function registerExamTools(server: McpServer, auth: AuthManager) {
153153
)`
154154
)
155155
.eq("exam_id", exam_id)
156+
.order("question_id", { referencedTable: "exam_questions" })
156157
.single();
157158

158159
if (error || !data) return errorResult(`Exam ${exam_id} not found.`);
@@ -294,6 +295,11 @@ export function registerExamTools(server: McpServer, auth: AuthManager) {
294295

295296
for (let i = 0; i < questions.length; i++) {
296297
const q = questions[i];
298+
if ((q.question_type === "multiple_choice" || q.question_type === "true_false") && (!q.options || q.options.length === 0)) {
299+
errors.push(`Q${i + 1}: type '${q.question_type}' requires at least one option`);
300+
continue;
301+
}
302+
297303
const { data: question, error: qError } = await supabase
298304
.from("exam_questions")
299305
.insert({
@@ -446,6 +452,10 @@ export function registerExamTools(server: McpServer, auth: AuthManager) {
446452
},
447453
async ({ exam_id, question_text, question_type, options, ai_grading_criteria, expected_keywords }) => {
448454
try {
455+
if ((question_type === "multiple_choice" || question_type === "true_false") && (!options || options.length === 0)) {
456+
return errorResult(`Questions of type '${question_type}' require at least one option.`);
457+
}
458+
449459
await auth.verifyExamOwnership(exam_id);
450460
const supabase = auth.getClient();
451461

@@ -564,6 +574,44 @@ export function registerExamTools(server: McpServer, auth: AuthManager) {
564574
}
565575
);
566576

577+
server.registerTool(
578+
"lms_delete_exam",
579+
{
580+
title: "Delete Exam",
581+
description:
582+
"Permanently delete an exam and all its questions, options, and submissions. This action is irreversible. Only use on draft exams or when sure there are no important submissions.",
583+
inputSchema: z.object({ exam_id: z.number().describe("The exam ID to delete") }).strict(),
584+
annotations: {
585+
readOnlyHint: false,
586+
destructiveHint: true,
587+
idempotentHint: false,
588+
openWorldHint: true,
589+
},
590+
},
591+
async ({ exam_id }) => {
592+
try {
593+
await auth.verifyExamOwnership(exam_id);
594+
const supabase = auth.getClient();
595+
596+
const { data: exam } = await supabase
597+
.from("exams")
598+
.select("title")
599+
.eq("exam_id", exam_id)
600+
.single();
601+
602+
const { error } = await supabase.from("exams").delete().eq("exam_id", exam_id);
603+
if (error) return errorResult(`Deleting exam: ${error.message}`);
604+
605+
return {
606+
content: [{ type: "text", text: `Exam "${exam?.title ?? exam_id}" (ID: ${exam_id}) and all its questions have been deleted.` }],
607+
structuredContent: { success: true, deleted_exam_id: exam_id },
608+
};
609+
} catch (err) {
610+
return errorResult(err instanceof Error ? err.message : String(err));
611+
}
612+
}
613+
);
614+
567615
server.registerTool(
568616
"lms_delete_exam_question",
569617
{
@@ -573,7 +621,7 @@ export function registerExamTools(server: McpServer, auth: AuthManager) {
573621
annotations: {
574622
readOnlyHint: false,
575623
destructiveHint: true,
576-
idempotentHint: true,
624+
idempotentHint: false,
577625
openWorldHint: true,
578626
},
579627
},

mcp-server/src/tools/exercises.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,10 @@ export function registerExerciseTools(server: McpServer, auth: AuthManager) {
212212
async ({ category }) => {
213213
try {
214214
const supabase = auth.getClient();
215-
let query = supabase.from("prompt_templates").select("id, name, category, description, variables");
215+
let query = supabase
216+
.from("prompt_templates")
217+
.select("id, name, category, description, variables")
218+
.or(`is_system.eq.true,created_by.eq.${auth.getUserId()}`);
216219
if (category) query = query.eq("category", category);
217220

218221
const { data, error } = await query;
@@ -324,6 +327,7 @@ export function registerExerciseTools(server: McpServer, auth: AuthManager) {
324327
system_prompt: finalSystemPrompt,
325328
time_limit: time_limit ?? null,
326329
created_by: auth.getUserId(),
330+
tenant_id: auth.getTenantId(),
327331
template_id: template_id ?? null,
328332
template_variables: template_variables ?? null,
329333
})
@@ -352,6 +356,43 @@ export function registerExerciseTools(server: McpServer, auth: AuthManager) {
352356
}
353357
);
354358

359+
server.registerTool(
360+
"lms_delete_exercise",
361+
{
362+
title: "Delete Exercise",
363+
description: "Permanently delete a practice exercise. This action is irreversible.",
364+
inputSchema: z.object({ exercise_id: z.number().describe("The exercise ID to delete") }).strict(),
365+
annotations: {
366+
readOnlyHint: false,
367+
destructiveHint: true,
368+
idempotentHint: false,
369+
openWorldHint: true,
370+
},
371+
},
372+
async ({ exercise_id }) => {
373+
try {
374+
await auth.verifyExerciseOwnership(exercise_id);
375+
const supabase = auth.getClient();
376+
377+
const { data: exercise } = await supabase
378+
.from("exercises")
379+
.select("title")
380+
.eq("id", exercise_id)
381+
.single();
382+
383+
const { error } = await supabase.from("exercises").delete().eq("id", exercise_id);
384+
if (error) return errorResult(`Deleting exercise: ${error.message}`);
385+
386+
return {
387+
content: [{ type: "text", text: `Exercise "${exercise?.title ?? exercise_id}" (ID: ${exercise_id}) has been deleted.` }],
388+
structuredContent: { success: true, deleted_exercise_id: exercise_id },
389+
};
390+
} catch (err) {
391+
return errorResult(err instanceof Error ? err.message : String(err));
392+
}
393+
}
394+
);
395+
355396
server.registerTool(
356397
"lms_update_exercise",
357398
{

mcp-server/src/tools/lessons.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export function registerLessonTools(server: McpServer, auth: AuthManager) {
210210
description: description ?? null,
211211
video_url: video_url || null,
212212
status: "draft",
213+
tenant_id: auth.getTenantId(),
213214
})
214215
.select("id, title, sequence, status")
215216
.single();
@@ -350,6 +351,92 @@ export function registerLessonTools(server: McpServer, auth: AuthManager) {
350351
}
351352
);
352353

354+
server.registerTool(
355+
"lms_delete_lesson",
356+
{
357+
title: "Delete Lesson",
358+
description:
359+
"Permanently delete a lesson and its associated AI task. This is irreversible. Student completion records for this lesson will also be removed.",
360+
inputSchema: z.object({ lesson_id: z.number().describe("The lesson ID to delete") }).strict(),
361+
annotations: {
362+
readOnlyHint: false,
363+
destructiveHint: true,
364+
idempotentHint: false,
365+
openWorldHint: true,
366+
},
367+
},
368+
async ({ lesson_id }) => {
369+
try {
370+
await auth.verifyLessonOwnership(lesson_id);
371+
const supabase = auth.getClient();
372+
373+
const { data: lesson } = await supabase
374+
.from("lessons")
375+
.select("title")
376+
.eq("id", lesson_id)
377+
.single();
378+
379+
const { error } = await supabase.from("lessons").delete().eq("id", lesson_id);
380+
if (error) return errorResult(`Deleting lesson: ${error.message}`);
381+
382+
return {
383+
content: [{ type: "text", text: `Lesson "${lesson?.title ?? lesson_id}" (ID: ${lesson_id}) has been deleted.` }],
384+
structuredContent: { success: true, deleted_lesson_id: lesson_id },
385+
};
386+
} catch (err) {
387+
return errorResult(err instanceof Error ? err.message : String(err));
388+
}
389+
}
390+
);
391+
392+
server.registerTool(
393+
"lms_get_lesson_ai_task",
394+
{
395+
title: "Get Lesson AI Task",
396+
description: "Get the AI task (instructions and system prompt) configured for a lesson.",
397+
inputSchema: z
398+
.object({
399+
lesson_id: z.number().describe("The lesson ID"),
400+
})
401+
.strict(),
402+
annotations: {
403+
readOnlyHint: true,
404+
destructiveHint: false,
405+
idempotentHint: true,
406+
openWorldHint: true,
407+
},
408+
},
409+
async ({ lesson_id }) => {
410+
try {
411+
await auth.verifyLessonOwnership(lesson_id);
412+
const supabase = auth.getClient();
413+
414+
const { data, error } = await supabase
415+
.from("lessons_ai_tasks")
416+
.select("id, lesson_id, task_instructions, system_prompt, created_at, updated_at")
417+
.eq("lesson_id", lesson_id)
418+
.maybeSingle();
419+
420+
if (error) return errorResult(`Getting AI task: ${error.message}`);
421+
if (!data) {
422+
return { content: [{ type: "text", text: `No AI task configured for lesson ${lesson_id}.` }] };
423+
}
424+
425+
return {
426+
content: [
427+
{
428+
type: "text",
429+
text: `# AI Task for Lesson ${lesson_id}\n\n**Task Instructions:**\n${data.task_instructions ?? "(not set)"}\n\n**System Prompt:**\n${data.system_prompt ?? "(not set)"}`,
430+
},
431+
],
432+
structuredContent: data,
433+
};
434+
} catch (err) {
435+
return errorResult(err instanceof Error ? err.message : String(err));
436+
}
437+
}
438+
);
439+
353440
// Tool to create or update the AI task attached to a lesson
354441
server.registerTool(
355442
"lms_upsert_lesson_ai_task",
@@ -372,6 +459,10 @@ export function registerLessonTools(server: McpServer, auth: AuthManager) {
372459
},
373460
async ({ lesson_id, task_instructions, system_prompt }) => {
374461
try {
462+
if (!task_instructions && !system_prompt) {
463+
return errorResult("At least one of task_instructions or system_prompt must be provided.");
464+
}
465+
375466
// Verify teacher owns the lesson (or is admin)
376467
await auth.verifyLessonOwnership(lesson_id);
377468
const supabase = auth.getClient();

0 commit comments

Comments
 (0)