Skip to content

Commit 4582025

Browse files
Merge pull request #271 from guillermoscript/feature/mcp
Feature/mcp
2 parents 39055a5 + f19d436 commit 4582025

8 files changed

Lines changed: 417 additions & 31 deletions

File tree

app/[locale]/oauth/mcp-authorize/mcp-consent-form.tsx

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client"
22

3-
import { useState } from "react"
3+
import { useState, useEffect } from "react"
44
import { Button } from "@/components/ui/button"
55

66
interface McpConsentFormProps {
@@ -11,32 +11,29 @@ interface McpConsentFormProps {
1111
mcpCallbackUrl: string
1212
}
1313

14-
/**
15-
* Client component for MCP OAuth consent approve/deny buttons.
16-
*
17-
* On approve: POSTs to MCP server /auth/callback with user identity.
18-
* The MCP server then generates an auth code and redirects the browser
19-
* back to the MCP client (Claude Desktop).
20-
*
21-
* On deny: Redirects back to MCP client with error=access_denied.
22-
*/
2314
export function McpConsentForm({
2415
sessionId,
2516
userId,
2617
userRole,
2718
tenantId,
2819
mcpCallbackUrl,
2920
}: McpConsentFormProps) {
30-
const [loading, setLoading] = useState<"approve" | "deny" | null>(null)
21+
const [state, setState] = useState<"idle" | "redirecting" | "success" | "denied" | "error">("idle")
3122
const [error, setError] = useState<string | null>(null)
3223

33-
const handleApprove = async () => {
34-
setLoading("approve")
24+
// After redirecting, show success state (the redirect may take a moment)
25+
useEffect(() => {
26+
if (state === "redirecting") {
27+
const timer = setTimeout(() => setState("success"), 1500)
28+
return () => clearTimeout(timer)
29+
}
30+
}, [state])
31+
32+
const handleApprove = () => {
33+
setState("redirecting")
3534
setError(null)
3635

3736
try {
38-
// Redirect to MCP server callback via GET with query params
39-
// The callback will generate an auth code and redirect back to Claude
4037
const url = new URL(mcpCallbackUrl)
4138
url.searchParams.set("session_id", sessionId)
4239
url.searchParams.set("user_id", userId)
@@ -45,19 +42,49 @@ export function McpConsentForm({
4542
window.location.href = url.toString()
4643
} catch (err) {
4744
setError(err instanceof Error ? err.message : "Failed to authorize")
48-
setLoading(null)
45+
setState("error")
4946
}
5047
}
5148

5249
const handleDeny = () => {
53-
setLoading("deny")
54-
// Close the window or show denied message
50+
setState("denied")
5551
window.close()
56-
// If window.close() doesn't work (not opened by script), show message
57-
setTimeout(() => {
58-
setLoading(null)
59-
setError("Authorization denied. You can close this window.")
60-
}, 500)
52+
}
53+
54+
if (state === "success") {
55+
return (
56+
<div className="space-y-4 text-center">
57+
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
58+
<svg className="h-6 w-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
59+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
60+
</svg>
61+
</div>
62+
<div>
63+
<p className="font-medium">Authorization complete</p>
64+
<p className="mt-1 text-sm text-muted-foreground">
65+
You can close this window and return to Claude Desktop.
66+
</p>
67+
</div>
68+
</div>
69+
)
70+
}
71+
72+
if (state === "denied") {
73+
return (
74+
<div className="space-y-4 text-center">
75+
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
76+
<svg className="h-6 w-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
77+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
78+
</svg>
79+
</div>
80+
<div>
81+
<p className="font-medium">Authorization denied</p>
82+
<p className="mt-1 text-sm text-muted-foreground">
83+
You can close this window.
84+
</p>
85+
</div>
86+
</div>
87+
)
6188
}
6289

6390
return (
@@ -70,19 +97,19 @@ export function McpConsentForm({
7097

7198
<Button
7299
onClick={handleApprove}
73-
disabled={loading !== null}
100+
disabled={state === "redirecting"}
74101
className="w-full"
75102
>
76-
{loading === "approve" ? "Authorizing..." : "Approve"}
103+
{state === "redirecting" ? "Authorizing..." : "Approve"}
77104
</Button>
78105

79106
<Button
80107
onClick={handleDeny}
81-
disabled={loading !== null}
108+
disabled={state === "redirecting"}
82109
variant="outline"
83110
className="w-full"
84111
>
85-
{loading === "deny" ? "Denying..." : "Deny"}
112+
Deny
86113
</Button>
87114
</div>
88115
)

app/api/mcp/[[...path]]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function getOrigin(request: NextRequest): string {
3838

3939
function getSubpath(request: NextRequest): string {
4040
const path = new URL(request.url).pathname;
41-
return path.replace(/^\/api\/mcp/, '') || '/';
41+
return path.replace(/^\/api\/mcp/, '') || '/mcp';
4242
}
4343

4444
// ─── OAuth Metadata (served directly by proxy, tenant-aware) ───────────────

mcp-server/src/http-server-oauth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,8 @@ app.get("/auth/callback", (req, res) => {
288288
});
289289

290290
// MCP endpoint (protected by OAuth bearer token)
291-
app.post("/mcp", bearerAuth, async (req, res) => {
291+
// Handle both /mcp and / (the proxy strips /api/mcp prefix, leaving "/" for the root path)
292+
app.post(["/mcp", "/"], bearerAuth, async (req, res) => {
292293
const authInfo = req.auth!;
293294
const userContext: UserContext = {
294295
userId: authInfo.extra?.userId as string,

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
{

0 commit comments

Comments
 (0)