a fancy canvas mcp server!

feat: move to oauth

dunkirk.sh 2a88c500 768823af

verified
+1167 -83
+21 -3
.env.example
··· 3 3 HOST=localhost 4 4 BASE_URL=http://localhost:3000 5 5 6 - # Encryption (generate with: bun run generate-key) 7 - ENCRYPTION_KEY=your_encryption_key_here 8 - 9 6 # Database 10 7 DATABASE_PATH=./canvas-mcp.db 8 + 9 + # Encryption (generate with: bun run generate-key) 10 + ENCRYPTION_KEY= 11 + 12 + # SMTP Configuration (Mailchannels) 13 + SMTP_HOST=smtp.mailchannels.net 14 + SMTP_PORT=587 15 + SMTP_USER=kieranklukascontracting 16 + SMTP_PASS= 17 + SMTP_FROM=canvas-mcp@dunkirk.sh 18 + 19 + # DKIM Configuration (optional but recommended) 20 + DKIM_SELECTOR=mailchannels 21 + DKIM_DOMAIN=dunkirk.sh 22 + DKIM_PRIVATE_KEY_FILE=./dkim_private.pem 23 + 24 + # JWT Keys for OAuth (generate with openssl) 25 + # openssl genrsa -out private.pem 2048 26 + # openssl rsa -in private.pem -pubout -out public.pem 27 + JWT_PRIVATE_KEY= 28 + JWT_PUBLIC_KEY=
+6
.gitignore
··· 1 1 node_modules/ 2 2 .env 3 + .env.email 3 4 *.db 4 5 *.db-shm 5 6 *.db-wal 6 7 dist/ 7 8 .DS_Store 9 + 10 + # Sensitive keys 11 + dkim_private.pem 12 + private.pem 13 + public.pem
+19
bun.lock
··· 6 6 "name": "canvas-mcp", 7 7 "dependencies": { 8 8 "@modelcontextprotocol/sdk": "^1.0.4", 9 + "nodemailer": "^8.0.1", 10 + "nodemailer-dkim": "^1.0.5", 9 11 "zod": "^3.23.8", 10 12 }, 11 13 "devDependencies": { 12 14 "@types/bun": "latest", 15 + "@types/nodemailer": "^7.0.10", 13 16 }, 14 17 }, 15 18 }, ··· 21 24 "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], 22 25 23 26 "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], 27 + 28 + "@types/nodemailer": ["@types/nodemailer@7.0.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g=="], 24 29 25 30 "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], 26 31 ··· 53 58 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 54 59 55 60 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], 61 + 62 + "dkim-signer": ["dkim-signer@0.2.2", "", { "dependencies": { "libmime": "^2.0.3" } }, "sha512-24OZ3cCA30UTRz+Plpg+ibfPq3h7tDtsJRg75Bo0pGakZePXcPBddY80bKi1Bi7Jsz7tL5Cw527mhCRDvNFgfg=="], 56 63 57 64 "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], 58 65 ··· 122 129 123 130 "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], 124 131 132 + "libbase64": ["libbase64@0.1.0", "", {}, "sha512-B91jifmFw1DKEqEWstSpg1PbtUbBzR4yQAPT86kCQXBtud1AJVA+Z6RSklSrqmKe4q2eiEufgnhqJKPgozzfIQ=="], 133 + 134 + "libmime": ["libmime@2.1.3", "", { "dependencies": { "iconv-lite": "0.4.15", "libbase64": "0.1.0", "libqp": "1.1.0" } }, "sha512-ABr2f4O+K99sypmkF/yPz2aXxUFHEZzv+iUkxItCeKZWHHXdQPpDXd6rV1kBBwL4PserzLU09EIzJ2lxC9hPfQ=="], 135 + 136 + "libqp": ["libqp@1.1.0", "", {}, "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="], 137 + 125 138 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 126 139 127 140 "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], ··· 135 148 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 136 149 137 150 "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], 151 + 152 + "nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="], 153 + 154 + "nodemailer-dkim": ["nodemailer-dkim@1.0.5", "", { "dependencies": { "dkim-signer": "^0.2.2" } }, "sha512-TenRft0rlXqPiFyz3XzN6blCdwez6JDL8dBhkdKaegIhE8xDfje37Z+8BiMG0CETACZV4Qlnw3BgHU1YUTfzsw=="], 138 155 139 156 "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], 140 157 ··· 205 222 "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], 206 223 207 224 "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], 225 + 226 + "libmime/iconv-lite": ["iconv-lite@0.4.15", "", {}, "sha512-RGR+c9Lm+tLsvU57FTJJtdbv2hQw42Yl2n26tVIBaYmZzLN+EGfroUugN/z9nJf9kOXd49hBmpoGr4FEm+A4pw=="], 208 227 } 209 228 }
+4 -1
package.json
··· 10 10 }, 11 11 "dependencies": { 12 12 "@modelcontextprotocol/sdk": "^1.0.4", 13 + "nodemailer": "^8.0.1", 14 + "nodemailer-dkim": "^1.0.5", 13 15 "zod": "^3.23.8" 14 16 }, 15 17 "devDependencies": { 16 - "@types/bun": "latest" 18 + "@types/bun": "latest", 19 + "@types/nodemailer": "^7.0.10" 17 20 } 18 21 }
+575
src/index.ts
··· 5 5 handleMcpRequest, 6 6 getProtectedResourceMetadata, 7 7 } from "./lib/mcp-transport.js"; 8 + import Mailer from "./lib/email.js"; 8 9 9 10 // Import HTML pages 10 11 import indexPage from "./public/index.html"; ··· 60 61 }, 61 62 }, 62 63 64 + // Protected Resource Metadata with MCP path 65 + "/.well-known/oauth-protected-resource/mcp": { 66 + GET() { 67 + return Response.json(getProtectedResourceMetadata(BASE_URL)); 68 + }, 69 + }, 70 + 71 + // Authorization Server Metadata (at root for discovery) 72 + "/.well-known/oauth-authorization-server/auth": { 73 + GET() { 74 + return Response.json({ 75 + issuer: `${BASE_URL}/auth`, 76 + authorization_endpoint: `${BASE_URL}/auth/authorize`, 77 + token_endpoint: `${BASE_URL}/auth/token`, 78 + code_challenge_methods_supported: ["S256"], 79 + grant_types_supported: ["authorization_code", "refresh_token"], 80 + response_types_supported: ["code"], 81 + scopes_supported: [ 82 + "canvas:read", 83 + "canvas:courses:read", 84 + "canvas:assignments:read", 85 + "canvas:grades:read", 86 + "canvas:announcements:read", 87 + ], 88 + token_endpoint_auth_methods_supported: ["none"], 89 + client_id_metadata_document_supported: true, 90 + }); 91 + }, 92 + }, 93 + 94 + // OpenID Connect Discovery (some clients look for this) 95 + "/.well-known/openid-configuration/auth": { 96 + GET() { 97 + return Response.json({ 98 + issuer: `${BASE_URL}/auth`, 99 + authorization_endpoint: `${BASE_URL}/auth/authorize`, 100 + token_endpoint: `${BASE_URL}/auth/token`, 101 + code_challenge_methods_supported: ["S256"], 102 + grant_types_supported: ["authorization_code", "refresh_token"], 103 + response_types_supported: ["code"], 104 + scopes_supported: [ 105 + "canvas:read", 106 + "canvas:courses:read", 107 + "canvas:assignments:read", 108 + "canvas:grades:read", 109 + "canvas:announcements:read", 110 + ], 111 + token_endpoint_auth_methods_supported: ["none"], 112 + }); 113 + }, 114 + }, 115 + 116 + "/auth/.well-known/openid-configuration": { 117 + GET() { 118 + return Response.json({ 119 + issuer: `${BASE_URL}/auth`, 120 + authorization_endpoint: `${BASE_URL}/auth/authorize`, 121 + token_endpoint: `${BASE_URL}/auth/token`, 122 + code_challenge_methods_supported: ["S256"], 123 + grant_types_supported: ["authorization_code", "refresh_token"], 124 + response_types_supported: ["code"], 125 + scopes_supported: [ 126 + "canvas:read", 127 + "canvas:courses:read", 128 + "canvas:assignments:read", 129 + "canvas:grades:read", 130 + "canvas:announcements:read", 131 + ], 132 + token_endpoint_auth_methods_supported: ["none"], 133 + }); 134 + }, 135 + }, 136 + 137 + // Dynamic client registration (return 501 Not Implemented for now) 138 + "/register": { 139 + POST() { 140 + return Response.json( 141 + { error: "dynamic_registration_not_supported", error_description: "Use Client ID Metadata Documents instead" }, 142 + { status: 501 } 143 + ); 144 + }, 145 + }, 146 + 147 + // OAuth authorization endpoint 148 + "/auth/authorize": { 149 + async GET(req: Request) { 150 + const url = new URL(req.url); 151 + const client_id = url.searchParams.get("client_id"); 152 + const redirect_uri = url.searchParams.get("redirect_uri"); 153 + const code_challenge = url.searchParams.get("code_challenge"); 154 + const code_challenge_method = url.searchParams.get("code_challenge_method"); 155 + const resource = url.searchParams.get("resource"); 156 + const scope = url.searchParams.get("scope") || "canvas:read"; 157 + const state = url.searchParams.get("state") || ""; 158 + const response_type = url.searchParams.get("response_type"); 159 + 160 + // Validate required parameters 161 + if (!client_id || !redirect_uri || !code_challenge || !response_type) { 162 + return new Response("Missing required OAuth parameters", { status: 400 }); 163 + } 164 + 165 + if (response_type !== "code") { 166 + return new Response("Only authorization_code flow is supported", { status: 400 }); 167 + } 168 + 169 + if (code_challenge_method !== "S256") { 170 + return new Response("Only S256 PKCE method is supported", { status: 400 }); 171 + } 172 + 173 + // Check if user is logged in 174 + const session = getSession(req); 175 + if (!session?.user_id) { 176 + // Redirect to login, preserving OAuth params 177 + return new Response(null, { 178 + status: 302, 179 + headers: { 180 + Location: `/?oauth_redirect=${encodeURIComponent(req.url)}`, 181 + }, 182 + }); 183 + } 184 + 185 + // Check if user has Canvas connected 186 + const user = DB.raw 187 + .query("SELECT * FROM users WHERE id = ?") 188 + .get(session.user_id) as any; 189 + 190 + if (!user || !user.canvas_domain) { 191 + return new Response(` 192 + <!DOCTYPE html> 193 + <html lang="en"> 194 + <head> 195 + <meta charset="UTF-8"> 196 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 197 + <title>Connect Canvas First</title> 198 + <style> 199 + * { margin: 0; padding: 0; box-sizing: border-box; } 200 + body { 201 + font-family: system-ui, -apple-system, sans-serif; 202 + line-height: 1.6; 203 + max-width: 600px; 204 + margin: 4rem auto; 205 + padding: 2rem; 206 + color: #111; 207 + } 208 + main { 209 + padding: 2rem; 210 + border: 1px solid #ddd; 211 + border-radius: 4px; 212 + } 213 + h1 { font-size: 1.5rem; margin-bottom: 1rem; font-weight: 600; } 214 + p { color: #555; margin-bottom: 1.5rem; } 215 + a { color: #0066cc; text-decoration: none; } 216 + a:hover { text-decoration: underline; } 217 + </style> 218 + </head> 219 + <body> 220 + <main> 221 + <h1>Connect Canvas First</h1> 222 + <p>You need to connect your Canvas account before authorizing AI access.</p> 223 + <p><a href="/dashboard">Go to Dashboard →</a></p> 224 + </main> 225 + </body> 226 + </html>`, { headers: { "Content-Type": "text/html" }}); 227 + } 228 + 229 + // Show consent page 230 + const consentHTML = ` 231 + <!DOCTYPE html> 232 + <html lang="en"> 233 + <head> 234 + <meta charset="UTF-8"> 235 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 236 + <title>Authorize Access</title> 237 + <style> 238 + * { margin: 0; padding: 0; box-sizing: border-box; } 239 + body { 240 + font-family: system-ui, -apple-system, sans-serif; 241 + line-height: 1.6; 242 + max-width: 600px; 243 + margin: 4rem auto; 244 + padding: 2rem; 245 + color: #111; 246 + } 247 + main { 248 + padding: 2rem; 249 + border: 1px solid #ddd; 250 + border-radius: 4px; 251 + } 252 + h1 { font-size: 1.5rem; margin-bottom: 1rem; font-weight: 600; } 253 + p { color: #555; margin-bottom: 1.5rem; } 254 + .scopes { 255 + background: #f9f9f9; 256 + padding: 1rem; 257 + border-radius: 4px; 258 + margin: 1.5rem 0; 259 + } 260 + .scope-item { 261 + padding: 0.5rem 0; 262 + border-bottom: 1px solid #eee; 263 + } 264 + .scope-item:last-child { border-bottom: none; } 265 + .buttons { 266 + display: flex; 267 + gap: 1rem; 268 + margin-top: 1.5rem; 269 + } 270 + button { 271 + flex: 1; 272 + padding: 0.75rem; 273 + border: none; 274 + border-radius: 4px; 275 + font-size: 1rem; 276 + cursor: pointer; 277 + } 278 + .approve { background: #0066cc; color: white; } 279 + .deny { background: #666; color: white; } 280 + </style> 281 + </head> 282 + <body> 283 + <main> 284 + <h1>Authorize Access</h1> 285 + <p><strong>${client_id.split('/')[2]}</strong> wants to access your Canvas data.</p> 286 + 287 + <div class="scopes"> 288 + <strong>Requested permissions:</strong> 289 + ${scope.split(" ").map(s => ` 290 + <div class="scope-item"> 291 + ${s.replace("canvas:", "").replace(/:read$/, "").replace(/_/g, " ")} 292 + </div> 293 + `).join("")} 294 + </div> 295 + 296 + <form method="POST" action="/auth/consent"> 297 + <input type="hidden" name="client_id" value="${client_id}"> 298 + <input type="hidden" name="redirect_uri" value="${redirect_uri}"> 299 + <input type="hidden" name="code_challenge" value="${code_challenge}"> 300 + <input type="hidden" name="code_challenge_method" value="${code_challenge_method}"> 301 + <input type="hidden" name="scope" value="${scope}"> 302 + <input type="hidden" name="state" value="${state}"> 303 + 304 + <div class="buttons"> 305 + <button type="submit" name="action" value="deny" class="deny">Deny</button> 306 + <button type="submit" name="action" value="approve" class="approve">Authorize</button> 307 + </div> 308 + </form> 309 + </main> 310 + </body> 311 + </html>`; 312 + 313 + return new Response(consentHTML, { 314 + headers: { "Content-Type": "text/html" }, 315 + }); 316 + }, 317 + }, 318 + 319 + // OAuth consent handler 320 + "/auth/consent": { 321 + async POST(req: Request) { 322 + const session = getSession(req); 323 + if (!session?.user_id) { 324 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 325 + } 326 + 327 + const formData = await req.formData(); 328 + const action = formData.get("action"); 329 + const client_id = formData.get("client_id") as string; 330 + const redirect_uri = formData.get("redirect_uri") as string; 331 + const code_challenge = formData.get("code_challenge") as string; 332 + const code_challenge_method = formData.get("code_challenge_method") as string; 333 + const scope = formData.get("scope") as string; 334 + const state = formData.get("state") as string; 335 + 336 + if (action === "deny") { 337 + return new Response(null, { 338 + status: 302, 339 + headers: { 340 + Location: `${redirect_uri}?error=access_denied&state=${state}`, 341 + }, 342 + }); 343 + } 344 + 345 + // Generate authorization code 346 + const authCode = randomBytes(32).toString("base64url"); 347 + 348 + // Store auth code in database 349 + DB.raw.run( 350 + `INSERT INTO auth_codes (code, user_id, client_id, redirect_uri, code_challenge, code_challenge_method, scope, expires_at) 351 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 352 + [ 353 + authCode, 354 + session.user_id, 355 + client_id, 356 + redirect_uri, 357 + code_challenge, 358 + code_challenge_method, 359 + scope, 360 + Date.now() + 10 * 60 * 1000, // 10 minutes 361 + ] 362 + ); 363 + 364 + // Redirect back with code 365 + return new Response(null, { 366 + status: 302, 367 + headers: { 368 + Location: `${redirect_uri}?code=${authCode}&state=${state}`, 369 + }, 370 + }); 371 + }, 372 + }, 373 + 374 + // OAuth token endpoint 375 + "/auth/token": { 376 + async POST(req: Request) { 377 + // OAuth 2.0 token requests use application/x-www-form-urlencoded 378 + const contentType = req.headers.get("content-type") || ""; 379 + let grant_type, code, redirect_uri, code_verifier, client_id, resource; 380 + 381 + if (contentType.includes("application/x-www-form-urlencoded")) { 382 + const formData = await req.formData(); 383 + grant_type = formData.get("grant_type") as string; 384 + code = formData.get("code") as string; 385 + redirect_uri = formData.get("redirect_uri") as string; 386 + code_verifier = formData.get("code_verifier") as string; 387 + client_id = formData.get("client_id") as string; 388 + resource = formData.get("resource") as string; 389 + } else { 390 + // Fall back to JSON for backwards compatibility 391 + const body = await req.json(); 392 + ({ grant_type, code, redirect_uri, code_verifier, client_id, resource } = body); 393 + } 394 + 395 + if (grant_type !== "authorization_code") { 396 + return Response.json( 397 + { error: "unsupported_grant_type" }, 398 + { status: 400 } 399 + ); 400 + } 401 + 402 + if (!code || !code_verifier || !client_id) { 403 + return Response.json( 404 + { error: "invalid_request", error_description: "Missing required parameters" }, 405 + { status: 400 } 406 + ); 407 + } 408 + 409 + // Look up auth code 410 + const authData = DB.raw 411 + .query("SELECT * FROM auth_codes WHERE code = ? AND expires_at > ?") 412 + .get(code, Date.now()) as any; 413 + 414 + if (!authData) { 415 + return Response.json( 416 + { error: "invalid_grant", error_description: "Invalid or expired authorization code" }, 417 + { status: 400 } 418 + ); 419 + } 420 + 421 + // Verify PKCE 422 + const hash = require("crypto").createHash("sha256").update(code_verifier).digest("base64url"); 423 + if (hash !== authData.code_challenge) { 424 + DB.raw.run("DELETE FROM auth_codes WHERE code = ?", [code]); 425 + return Response.json( 426 + { error: "invalid_grant", error_description: "PKCE validation failed" }, 427 + { status: 400 } 428 + ); 429 + } 430 + 431 + // Verify client_id and redirect_uri match 432 + if (client_id !== authData.client_id || redirect_uri !== authData.redirect_uri) { 433 + return Response.json( 434 + { error: "invalid_grant", error_description: "Client ID or redirect URI mismatch" }, 435 + { status: 400 } 436 + ); 437 + } 438 + 439 + // Generate OAuth access token 440 + const accessToken = DB.createOAuthToken(authData.user_id, authData.scope, 86400000); 441 + 442 + // Delete used auth code 443 + DB.raw.run("DELETE FROM auth_codes WHERE code = ?", [code]); 444 + 445 + return Response.json({ 446 + access_token: accessToken, 447 + token_type: "Bearer", 448 + expires_in: 86400, // 24 hours 449 + scope: authData.scope, 450 + }); 451 + }, 452 + }, 453 + 63 454 // Auth endpoints 64 455 "/api/auth/token-login": { 65 456 async POST(req: Request) { ··· 89 480 ); 90 481 } 91 482 483 + // Check if user is already logged in (via magic link) 484 + const session = getSession(req); 485 + if (session?.user_id) { 486 + // Update existing magic link user with Canvas credentials 487 + const { apiKey } = await DB.updateUserCanvas( 488 + session.user_id, 489 + canvasUser.id.toString(), 490 + canvas_domain, 491 + access_token 492 + ); 493 + 494 + // Store API key in session if just generated (so user can see it) 495 + if (apiKey) { 496 + DB.updateSession(session.id, { api_key: apiKey }); 497 + } 498 + 499 + return Response.json({ success: true }); 500 + } 501 + 92 502 // Create or update user 93 503 const { user, apiKey, isNewUser } = await DB.createOrUpdateUser({ 94 504 canvas_user_id: canvasUser.id.toString(), ··· 142 552 }, 143 553 }, 144 554 ); 555 + }, 556 + }, 557 + 558 + // Magic link authentication 559 + "/api/auth/request-magic-link": { 560 + async POST(req: Request) { 561 + try { 562 + const { email } = await req.json(); 563 + 564 + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 565 + return Response.json( 566 + { error: "Valid email is required" }, 567 + { status: 400 } 568 + ); 569 + } 570 + 571 + // Rate limiting: 1 email per minute per address 572 + const cooldownMs = 60 * 1000; // 1 minute 573 + if (!DB.canSendMagicLink(email, cooldownMs)) { 574 + const lastSent = DB.getLastMagicLinkTime(email); 575 + const waitTime = lastSent 576 + ? Math.ceil((lastSent + cooldownMs - Date.now()) / 1000) 577 + : 60; 578 + 579 + return Response.json( 580 + { 581 + error: `Please wait ${waitTime} seconds before requesting another link`, 582 + }, 583 + { status: 429 } 584 + ); 585 + } 586 + 587 + // Generate magic link token 588 + const token = randomBytes(32).toString("base64url"); 589 + const expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes 590 + 591 + // Store magic link 592 + DB.createMagicLink(email, token, expiresAt); 593 + 594 + // Send email 595 + try { 596 + await Mailer.sendMagicLink(email, token); 597 + } catch (error: any) { 598 + console.error("Failed to send magic link email:", error); 599 + return Response.json( 600 + { error: "Failed to send email. Please try again." }, 601 + { status: 500 } 602 + ); 603 + } 604 + 605 + return Response.json({ 606 + success: true, 607 + message: "Check your email for a sign-in link", 608 + }); 609 + } catch (error: any) { 610 + console.error("Magic link error:", error); 611 + return Response.json( 612 + { error: "Failed to send magic link" }, 613 + { status: 500 } 614 + ); 615 + } 616 + }, 617 + }, 618 + 619 + "/auth/verify": { 620 + async GET(req: Request) { 621 + const url = new URL(req.url); 622 + const token = url.searchParams.get("token"); 623 + 624 + if (!token) { 625 + return new Response("Missing token", { status: 400 }); 626 + } 627 + 628 + const magicLink = DB.getMagicLink(token); 629 + if (!magicLink) { 630 + return new Response( 631 + `<!DOCTYPE html> 632 + <html lang="en"> 633 + <head> 634 + <meta charset="UTF-8"> 635 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 636 + <title>Invalid Link - Canvas MCP</title> 637 + <style> 638 + * { 639 + margin: 0; 640 + padding: 0; 641 + box-sizing: border-box; 642 + } 643 + body { 644 + font-family: system-ui, -apple-system, sans-serif; 645 + line-height: 1.6; 646 + max-width: 600px; 647 + margin: 4rem auto; 648 + padding: 2rem; 649 + color: #111; 650 + } 651 + main { 652 + padding: 2rem; 653 + border: 1px solid #ddd; 654 + border-radius: 4px; 655 + } 656 + h1 { 657 + font-size: 1.5rem; 658 + margin-bottom: 1rem; 659 + font-weight: 600; 660 + } 661 + p { 662 + color: #555; 663 + margin-bottom: 1.5rem; 664 + } 665 + a { 666 + color: #0066cc; 667 + text-decoration: none; 668 + } 669 + a:hover { 670 + text-decoration: underline; 671 + } 672 + </style> 673 + </head> 674 + <body> 675 + <main> 676 + <h1>Invalid or Expired Link</h1> 677 + <p>This sign-in link is invalid or has expired.</p> 678 + <p><a href="/">Request a new link</a></p> 679 + </main> 680 + </body> 681 + </html>`, 682 + { headers: { "Content-Type": "text/html" } } 683 + ); 684 + } 685 + 686 + // Mark as used 687 + DB.markMagicLinkUsed(token); 688 + 689 + // Check if user exists 690 + let user = DB.getUserByEmail(magicLink.email); 691 + 692 + // If no user, create a placeholder (they'll add Canvas later) 693 + if (!user) { 694 + const result = DB.raw 695 + .prepare( 696 + "INSERT INTO users (email) VALUES (?)" 697 + ) 698 + .run(magicLink.email); 699 + user = { id: Number(result.lastInsertRowid), email: magicLink.email }; 700 + } 701 + 702 + // Create session 703 + const sessionId = generateSessionId(); 704 + DB.createSession(sessionId, { 705 + canvas_domain: user.canvas_domain || "", 706 + state: "", 707 + user_id: user.id, 708 + maxAge: 2592000, // 30 days 709 + }); 710 + 711 + return new Response(null, { 712 + status: 302, 713 + headers: { 714 + Location: "/dashboard", 715 + "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax${ 716 + BASE_URL.startsWith("https") ? "; Secure" : "" 717 + }`, 718 + }, 719 + }); 145 720 }, 146 721 }, 147 722
+201 -16
src/lib/db.ts
··· 7 7 db.exec(` 8 8 CREATE TABLE IF NOT EXISTS users ( 9 9 id INTEGER PRIMARY KEY AUTOINCREMENT, 10 - canvas_user_id TEXT UNIQUE NOT NULL, 11 - canvas_domain TEXT NOT NULL, 10 + canvas_user_id TEXT, 11 + canvas_domain TEXT, 12 12 email TEXT, 13 - canvas_access_token TEXT NOT NULL, 13 + canvas_access_token TEXT, 14 14 canvas_refresh_token TEXT, 15 - mcp_api_key TEXT UNIQUE NOT NULL, 16 - created_at INTEGER NOT NULL, 15 + mcp_api_key TEXT UNIQUE, 16 + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), 17 17 last_used_at INTEGER, 18 18 token_expires_at INTEGER 19 19 ); ··· 36 36 expires_at INTEGER NOT NULL 37 37 ); 38 38 39 + CREATE TABLE IF NOT EXISTS magic_links ( 40 + id INTEGER PRIMARY KEY AUTOINCREMENT, 41 + email TEXT NOT NULL, 42 + token TEXT UNIQUE NOT NULL, 43 + expires_at INTEGER NOT NULL, 44 + used INTEGER DEFAULT 0, 45 + created_at INTEGER DEFAULT (unixepoch() * 1000) 46 + ); 47 + 48 + CREATE TABLE IF NOT EXISTS auth_codes ( 49 + code TEXT PRIMARY KEY, 50 + user_id INTEGER NOT NULL, 51 + client_id TEXT NOT NULL, 52 + redirect_uri TEXT NOT NULL, 53 + code_challenge TEXT NOT NULL, 54 + code_challenge_method TEXT NOT NULL, 55 + scope TEXT NOT NULL, 56 + expires_at INTEGER NOT NULL, 57 + created_at INTEGER DEFAULT (unixepoch() * 1000), 58 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 59 + ); 60 + 61 + CREATE TABLE IF NOT EXISTS oauth_tokens ( 62 + token TEXT PRIMARY KEY, 63 + user_id INTEGER NOT NULL, 64 + scope TEXT NOT NULL, 65 + expires_at INTEGER NOT NULL, 66 + created_at INTEGER DEFAULT (unixepoch() * 1000), 67 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 68 + ); 69 + 39 70 CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(mcp_api_key); 40 71 CREATE INDEX IF NOT EXISTS idx_users_canvas_id ON users(canvas_user_id); 72 + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); 41 73 CREATE INDEX IF NOT EXISTS idx_usage_logs_user_id ON usage_logs(user_id); 42 74 CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); 75 + CREATE INDEX IF NOT EXISTS idx_magic_links_token ON magic_links(token); 76 + CREATE INDEX IF NOT EXISTS idx_magic_links_email ON magic_links(email); 77 + CREATE INDEX IF NOT EXISTS idx_auth_codes_code ON auth_codes(code); 78 + CREATE INDEX IF NOT EXISTS idx_oauth_tokens_token ON oauth_tokens(token); 43 79 `); 44 80 45 81 // Encryption utilities ··· 124 160 ? encrypt(data.canvas_refresh_token) 125 161 : null; 126 162 127 - // Check if user exists 128 - const existing = db 163 + // Check if user exists by canvas_user_id or email 164 + let existing = db 129 165 .query("SELECT * FROM users WHERE canvas_user_id = ?") 130 166 .get(data.canvas_user_id) as User | null; 131 167 168 + // If not found by canvas_user_id, check by email (for magic link users) 169 + if (!existing && data.email) { 170 + existing = db 171 + .query("SELECT * FROM users WHERE email = ? AND canvas_user_id IS NULL") 172 + .get(data.email) as User | null; 173 + } 174 + 132 175 if (existing) { 133 - // Update existing user 176 + // Check if user needs an API key (magic link users) 177 + let apiKey: string | null = null; 178 + let hashedApiKey = existing.mcp_api_key; 179 + 180 + if (!hashedApiKey) { 181 + // Generate API key for magic link users connecting Canvas for first time 182 + apiKey = generateApiKey(); 183 + hashedApiKey = await hashApiKey(apiKey); 184 + } 185 + 186 + // Update existing user (might not have canvas_user_id if from magic link) 134 187 db.run( 135 188 `UPDATE users SET 189 + canvas_user_id = ?, 190 + canvas_domain = ?, 136 191 canvas_access_token = ?, 137 192 canvas_refresh_token = ?, 138 193 token_expires_at = ?, 139 - last_used_at = ? 140 - WHERE canvas_user_id = ?`, 194 + last_used_at = ?, 195 + mcp_api_key = ? 196 + WHERE id = ?`, 141 197 [ 198 + data.canvas_user_id, 199 + data.canvas_domain, 142 200 encryptedToken, 143 201 encryptedRefreshToken, 144 202 data.token_expires_at, 145 203 Date.now(), 146 - data.canvas_user_id, 204 + hashedApiKey, 205 + existing.id, 147 206 ] 148 207 ); 149 208 150 209 const user = db 151 - .query("SELECT * FROM users WHERE canvas_user_id = ?") 152 - .get(data.canvas_user_id) as User; 210 + .query("SELECT * FROM users WHERE id = ?") 211 + .get(existing.id) as User; 153 212 154 - // Return null for existing users - they need to regenerate if they lost it 155 - // We can't return the plaintext key since it's hashed in the database 156 - return { user, apiKey: null, isNewUser: false }; 213 + // Return API key only if we just generated it (for magic link users) 214 + const isNewUser = apiKey !== null; 215 + return { user, apiKey, isNewUser }; 157 216 } else { 158 217 // Create new user with API key 159 218 const apiKey = generateApiKey(); ··· 327 386 return db 328 387 .query("SELECT * FROM sessions WHERE api_key = ? AND expires_at > ?") 329 388 .get(token, Date.now()) as any; 389 + }, 390 + 391 + // Magic link authentication 392 + createMagicLink(email: string, token: string, expiresAt: number) { 393 + return db.run( 394 + "INSERT INTO magic_links (email, token, expires_at) VALUES (?, ?, ?)", 395 + [email, token, expiresAt] 396 + ); 397 + }, 398 + 399 + getMagicLink(token: string) { 400 + // Clean up expired magic links 401 + db.run("DELETE FROM magic_links WHERE expires_at < ?", [Date.now()]); 402 + 403 + return db 404 + .query( 405 + "SELECT * FROM magic_links WHERE token = ? AND expires_at > ? AND used = 0" 406 + ) 407 + .get(token, Date.now()) as any; 408 + }, 409 + 410 + markMagicLinkUsed(token: string) { 411 + return db.run("UPDATE magic_links SET used = 1 WHERE token = ?", [token]); 412 + }, 413 + 414 + getUserByEmail(email: string) { 415 + return db.query("SELECT * FROM users WHERE email = ?").get(email) as any; 416 + }, 417 + 418 + // Update user with Canvas credentials (for magic link users) 419 + async updateUserCanvas( 420 + userId: number, 421 + canvasUserId: string, 422 + canvasDomain: string, 423 + canvasToken: string 424 + ): Promise<{ apiKey: string | null }> { 425 + const existing = db.query("SELECT * FROM users WHERE id = ?").get(userId) as User | null; 426 + 427 + if (!existing) { 428 + throw new Error("User not found"); 429 + } 430 + 431 + const encryptedToken = encrypt(canvasToken); 432 + 433 + // Generate API key if user doesn't have one 434 + let apiKey: string | null = null; 435 + let hashedApiKey = existing.mcp_api_key; 436 + 437 + if (!hashedApiKey) { 438 + apiKey = generateApiKey(); 439 + hashedApiKey = await hashApiKey(apiKey); 440 + } 441 + 442 + // Update user with Canvas credentials 443 + db.run( 444 + `UPDATE users SET 445 + canvas_user_id = ?, 446 + canvas_domain = ?, 447 + canvas_access_token = ?, 448 + mcp_api_key = ?, 449 + last_used_at = ? 450 + WHERE id = ?`, 451 + [ 452 + canvasUserId, 453 + canvasDomain, 454 + encryptedToken, 455 + hashedApiKey, 456 + Date.now(), 457 + userId, 458 + ] 459 + ); 460 + 461 + return { apiKey }; 462 + }, 463 + 464 + // Rate limiting for magic links 465 + canSendMagicLink(email: string, cooldownMs: number = 60000): boolean { 466 + // Clean up old magic links first 467 + db.run("DELETE FROM magic_links WHERE expires_at < ?", [Date.now()]); 468 + 469 + // Check if a magic link was sent recently (within cooldown period) 470 + const recent = db 471 + .query( 472 + "SELECT * FROM magic_links WHERE email = ? AND created_at > ? ORDER BY created_at DESC LIMIT 1" 473 + ) 474 + .get(email, Date.now() - cooldownMs) as any; 475 + 476 + return !recent; 477 + }, 478 + 479 + getLastMagicLinkTime(email: string): number | null { 480 + const recent = db 481 + .query( 482 + "SELECT created_at FROM magic_links WHERE email = ? ORDER BY created_at DESC LIMIT 1" 483 + ) 484 + .get(email) as any; 485 + 486 + return recent ? recent.created_at : null; 487 + }, 488 + 489 + // OAuth tokens 490 + createOAuthToken(userId: number, scope: string, expiresIn: number = 86400000): string { 491 + const token = generateApiKey(); // Reuse the API key generator 492 + const expiresAt = Date.now() + expiresIn; 493 + 494 + db.run( 495 + "INSERT INTO oauth_tokens (token, user_id, scope, expires_at) VALUES (?, ?, ?, ?)", 496 + [token, userId, scope, expiresAt] 497 + ); 498 + 499 + return token; 500 + }, 501 + 502 + getUserByOAuthToken(token: string): User | null { 503 + // Clean up expired tokens 504 + db.run("DELETE FROM oauth_tokens WHERE expires_at < ?", [Date.now()]); 505 + 506 + const tokenData = db 507 + .query("SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > ?") 508 + .get(token, Date.now()) as any; 509 + 510 + if (!tokenData) { 511 + return null; 512 + } 513 + 514 + return db.query("SELECT * FROM users WHERE id = ?").get(tokenData.user_id) as User | null; 330 515 }, 331 516 }; 332 517
+170
src/lib/email.ts
··· 1 + import nodemailer from "nodemailer"; 2 + import { readFileSync } from "fs"; 3 + import dkim from "nodemailer-dkim"; 4 + 5 + const SMTP_HOST = process.env.SMTP_HOST; 6 + const SMTP_PORT = process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT) : undefined; 7 + const SMTP_USER = process.env.SMTP_USER; 8 + const SMTP_PASS = process.env.SMTP_PASS; 9 + const SMTP_FROM = process.env.SMTP_FROM; 10 + const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; 11 + 12 + // DKIM Configuration (optional) 13 + const DKIM_SELECTOR = process.env.DKIM_SELECTOR; 14 + const DKIM_DOMAIN = process.env.DKIM_DOMAIN; 15 + const DKIM_PRIVATE_KEY_FILE = process.env.DKIM_PRIVATE_KEY_FILE; 16 + 17 + class Mailer { 18 + private transporter: any; 19 + private enabled: boolean; 20 + 21 + constructor() { 22 + // Check if SMTP is configured 23 + if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASS || !SMTP_FROM) { 24 + console.warn("SMTP not configured - email functionality disabled"); 25 + this.enabled = false; 26 + return; 27 + } 28 + 29 + this.enabled = true; 30 + 31 + // Create SMTP transporter 32 + this.transporter = nodemailer.createTransport({ 33 + host: SMTP_HOST, 34 + port: SMTP_PORT, 35 + secure: false, // Use STARTTLS 36 + auth: { 37 + user: SMTP_USER, 38 + pass: SMTP_PASS, 39 + }, 40 + }); 41 + 42 + // Add DKIM signing if configured 43 + if (DKIM_SELECTOR && DKIM_DOMAIN && DKIM_PRIVATE_KEY_FILE) { 44 + try { 45 + const dkimPrivateKey = readFileSync(DKIM_PRIVATE_KEY_FILE, "utf-8"); 46 + this.transporter.use( 47 + "stream", 48 + dkim.signer({ 49 + domainName: DKIM_DOMAIN, 50 + keySelector: DKIM_SELECTOR, 51 + privateKey: dkimPrivateKey, 52 + headerFieldNames: "from:to:subject:date:message-id", 53 + }) 54 + ); 55 + console.log("DKIM signing enabled"); 56 + } catch (error) { 57 + console.warn("DKIM private key not found, emails will not be signed"); 58 + } 59 + } 60 + } 61 + 62 + private async sendMail( 63 + to: string, 64 + subject: string, 65 + html: string, 66 + text: string 67 + ): Promise<void> { 68 + if (!this.enabled) { 69 + throw new Error("Email is not configured"); 70 + } 71 + 72 + await this.transporter.sendMail({ 73 + from: SMTP_FROM, 74 + to, 75 + subject, 76 + text, 77 + html, 78 + headers: { 79 + "X-Mailer": "Canvas MCP", 80 + }, 81 + }); 82 + } 83 + 84 + async sendMagicLink(email: string, token: string): Promise<void> { 85 + const magicLink = `${BASE_URL}/auth/verify?token=${token}`; 86 + 87 + const html = `<!DOCTYPE html> 88 + <html> 89 + <head> 90 + <meta charset="utf-8"> 91 + <meta name="viewport" content="width=device-width, initial-scale=1"> 92 + <style> 93 + img { max-width: 100%; height: auto; } 94 + </style> 95 + </head> 96 + <body style="font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 40px auto; padding: 20px;"> 97 + <div> 98 + <h1 style="margin-bottom: 20px;">Sign in to Canvas MCP</h1> 99 + <p>Click this link to sign in:</p> 100 + <p><a href="${magicLink}" style="color: #0066cc;">${magicLink}</a></p> 101 + <hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;"> 102 + <p style="font-size: 12px; color: #999;">This link expires in 15 minutes. If you didn't request this, ignore it.</p> 103 + </div> 104 + </body> 105 + </html>`; 106 + 107 + const text = `Sign in to Canvas MCP 108 + 109 + Click this link to sign in: 110 + ${magicLink} 111 + 112 + This link expires in 15 minutes. 113 + If you didn't request this, you can safely ignore it.`; 114 + 115 + await this.sendMail(email, "Sign in to Canvas MCP", html, text); 116 + } 117 + 118 + async sendOAuthConfirmation( 119 + email: string, 120 + canvasDomain: string 121 + ): Promise<void> { 122 + const html = `<!DOCTYPE html> 123 + <html> 124 + <head> 125 + <meta charset="utf-8"> 126 + <meta name="viewport" content="width=device-width, initial-scale=1"> 127 + <style> 128 + img { max-width: 100%; height: auto; } 129 + </style> 130 + </head> 131 + <body style="font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 40px auto; padding: 20px;"> 132 + <div> 133 + <h1 style="margin-bottom: 20px;">Canvas Account Connected</h1> 134 + <div style="background: #d4edda; color: #0a6640; padding: 16px; border-radius: 4px; margin: 20px 0;"> 135 + Your Canvas account has been successfully connected! 136 + </div> 137 + <p><strong>Canvas Domain:</strong> <code style="background: #f5f5f5; padding: 2px 6px; border-radius: 3px;">${canvasDomain}</code></p> 138 + <p><a href="${BASE_URL}/dashboard" style="color: #0066cc;">View Dashboard →</a></p> 139 + <hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;"> 140 + <h2 style="font-size: 18px;">Next Steps</h2> 141 + <ol style="padding-left: 20px;"> 142 + <li>Configure Claude Desktop with the MCP server URL</li> 143 + <li>Authorize Claude to access your Canvas data</li> 144 + <li>Start asking questions about your courses!</li> 145 + </ol> 146 + </div> 147 + </body> 148 + </html>`; 149 + 150 + const text = `Canvas Account Connected! 151 + 152 + Your Canvas account (${canvasDomain}) has been successfully connected. 153 + 154 + Visit your dashboard: ${BASE_URL}/dashboard 155 + 156 + Next Steps: 157 + 1. Configure Claude Desktop with the MCP server URL 158 + 2. Authorize Claude to access your Canvas data 159 + 3. Start asking questions about your courses!`; 160 + 161 + await this.sendMail( 162 + email, 163 + "Canvas Account Connected - Canvas MCP", 164 + html, 165 + text 166 + ); 167 + } 168 + } 169 + 170 + export default new Mailer();
+11 -4
src/lib/mcp-transport.ts
··· 10 10 ): Promise<Response> { 11 11 // Validate API token 12 12 if (!apiToken) { 13 + const baseUrl = process.env.BASE_URL || "http://localhost:3000"; 13 14 return new Response( 14 15 JSON.stringify({ 15 16 jsonrpc: "2.0", ··· 23 24 status: 401, 24 25 headers: { 25 26 "Content-Type": "application/json", 26 - "WWW-Authenticate": `Bearer realm="Canvas MCP Server"`, 27 + "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="canvas:read canvas:courses:read"`, 27 28 }, 28 29 } 29 30 ); 30 31 } 31 32 32 - // Look up user by API key 33 - const user = await DB.getUserByApiKey(apiToken); 33 + // Look up user by API key or OAuth token 34 + let user = await DB.getUserByApiKey(apiToken); 35 + 36 + // If not found as API key, try as OAuth token 37 + if (!user) { 38 + user = DB.getUserByOAuthToken(apiToken); 39 + } 40 + 34 41 if (!user) { 35 42 return new Response( 36 43 JSON.stringify({ 37 44 jsonrpc: "2.0", 38 45 error: { 39 46 code: -32001, 40 - message: "Invalid or expired API token", 47 + message: "Invalid or expired token", 41 48 }, 42 49 id: null, 43 50 }),
+107 -8
src/public/dashboard.html
··· 205 205 <button class="logout-btn" id="logoutBtn">Logout</button> 206 206 </header> 207 207 208 - <section> 208 + <section id="connectCanvasSection" style="display: none;"> 209 + <h2>Connect Canvas Account</h2> 210 + <p style="color: #666; margin-bottom: 1.5rem;"> 211 + Connect your Canvas account to start using the MCP server with Claude. 212 + </p> 213 + <form id="connectCanvasForm"> 214 + <div style="margin-bottom: 1rem;"> 215 + <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Canvas Domain</label> 216 + <input 217 + type="text" 218 + id="canvasDomain" 219 + placeholder="canvas.university.edu" 220 + style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem;" 221 + required 222 + /> 223 + </div> 224 + <div style="margin-bottom: 1rem;"> 225 + <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Personal Access Token</label> 226 + <input 227 + type="password" 228 + id="accessToken" 229 + placeholder="Get this from Canvas → Settings → New Access Token" 230 + style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem;" 231 + required 232 + /> 233 + </div> 234 + <button type="submit" style="width: 100%; padding: 0.75rem;">Connect Canvas</button> 235 + <div id="connectError" style="display: none; margin-top: 1rem; padding: 0.75rem; background: #fee; border: 1px solid #fcc; border-radius: 4px; color: #c33;"></div> 236 + </form> 237 + <details style="margin-top: 1.5rem;"> 238 + <summary style="cursor: pointer; color: #666;">How to get a Personal Access Token</summary> 239 + <ol style="margin-top: 1rem; padding-left: 1.5rem; color: #666; font-size: 0.9rem;"> 240 + <li>Log in to your Canvas account</li> 241 + <li>Go to Account → Settings</li> 242 + <li>Scroll to "Approved Integrations"</li> 243 + <li>Click "+ New Access Token"</li> 244 + <li>Set Purpose: "MCP Server"</li> 245 + <li>Click "Generate Token"</li> 246 + <li>Copy the token and paste it above</li> 247 + </ol> 248 + </details> 249 + </section> 250 + 251 + <section id="accountSection" style="display: none;"> 209 252 <h2>Account Information</h2> 210 253 <div class="info-grid" id="accountInfo"> 211 254 <div class="info-row"> ··· 214 257 </div> 215 258 </section> 216 259 217 - <section> 260 + <section id="mcpConnectionSection" style="display: none;"> 218 261 <h2>MCP Server Connection</h2> 219 262 <p style="color: #666; margin-bottom: 1.5rem;"> 220 263 Use these credentials to connect your MCP client (Claude Desktop, Cursor, etc.) to your Canvas account. ··· 264 307 </div> 265 308 </section> 266 309 267 - <section> 310 + <section id="quickSetupSection" style="display: none;"> 268 311 <h2>Quick Setup</h2> 269 312 <p style="color: #666; margin-bottom: 1rem;"> 270 313 Add this configuration to your Claude Desktop config file: ··· 281 324 <button id="copyConfigBtn" style="margin-top: 1rem;">Copy Configuration</button> 282 325 </section> 283 326 284 - <section> 327 + <section id="usageStatsSection" style="display: none;"> 285 328 <h2>Usage Statistics</h2> 286 329 <div class="stat-grid" id="usageStats"> 287 330 <div class="stat"> ··· 309 352 } 310 353 311 354 userData = await response.json(); 312 - renderAccountInfo(); 313 - renderUsageStats(); 314 - setupApiKeyDisplay(); 315 - updateMCPConfig(); 355 + 356 + // Check if Canvas is connected 357 + if (!userData.canvas_domain) { 358 + // Show Canvas connection form, hide everything else 359 + document.getElementById('connectCanvasSection').style.display = 'block'; 360 + document.getElementById('accountSection').style.display = 'none'; 361 + document.getElementById('mcpConnectionSection').style.display = 'none'; 362 + document.getElementById('quickSetupSection').style.display = 'none'; 363 + document.getElementById('usageStatsSection').style.display = 'none'; 364 + } else { 365 + // Show everything when Canvas is connected 366 + document.getElementById('connectCanvasSection').style.display = 'none'; 367 + document.getElementById('accountSection').style.display = 'block'; 368 + document.getElementById('mcpConnectionSection').style.display = 'block'; 369 + document.getElementById('quickSetupSection').style.display = 'block'; 370 + document.getElementById('usageStatsSection').style.display = 'block'; 371 + renderAccountInfo(); 372 + renderUsageStats(); 373 + setupApiKeyDisplay(); 374 + updateMCPConfig(); 375 + } 316 376 } catch (error) { 317 377 console.error('Failed to load dashboard:', error); 318 378 window.location.href = '/'; ··· 455 515 credentials: 'include' 456 516 }); 457 517 window.location.href = '/'; 518 + }); 519 + 520 + document.getElementById('connectCanvasForm')?.addEventListener('submit', async (e) => { 521 + e.preventDefault(); 522 + 523 + const domain = document.getElementById('canvasDomain').value.trim(); 524 + const token = document.getElementById('accessToken').value.trim(); 525 + const errorDiv = document.getElementById('connectError'); 526 + const submitBtn = e.target.querySelector('button[type="submit"]'); 527 + 528 + errorDiv.style.display = 'none'; 529 + submitBtn.disabled = true; 530 + submitBtn.textContent = 'Connecting...'; 531 + 532 + try { 533 + const response = await fetch('/api/auth/token-login', { 534 + method: 'POST', 535 + headers: { 'Content-Type': 'application/json' }, 536 + credentials: 'include', 537 + body: JSON.stringify({ 538 + canvas_domain: domain, 539 + access_token: token 540 + }) 541 + }); 542 + 543 + const data = await response.json(); 544 + 545 + if (!response.ok) { 546 + throw new Error(data.error || 'Failed to connect Canvas'); 547 + } 548 + 549 + // Reload dashboard to show connected state 550 + window.location.reload(); 551 + } catch (error) { 552 + errorDiv.textContent = error.message; 553 + errorDiv.style.display = 'block'; 554 + submitBtn.disabled = false; 555 + submitBtn.textContent = 'Connect Canvas'; 556 + } 458 557 }); 459 558 460 559 loadDashboard();
+53 -51
src/public/index.html
··· 120 120 display: block; 121 121 } 122 122 123 + .success { 124 + margin-top: 1rem; 125 + padding: 0.75rem; 126 + background: #d4edda; 127 + border: 1px solid #c3e6cb; 128 + border-radius: 4px; 129 + color: #155724; 130 + display: none; 131 + } 132 + 133 + .success.show { 134 + display: block; 135 + } 136 + 123 137 footer { 124 138 margin-top: 4rem; 125 139 padding-top: 2rem; ··· 137 151 </header> 138 152 139 153 <section> 140 - <h2>Features</h2> 154 + <h2>How It Works</h2> 141 155 <ul> 142 - <li>Direct integration with Claude Desktop and other MCP clients</li> 143 - <li>Personal Access Token authentication (no admin access needed)</li> 144 - <li>Access courses, assignments, and grades from your AI assistant</li> 156 + <li>Sign in with your email (no password needed)</li> 157 + <li>Connect your Canvas account with OAuth 2.1</li> 158 + <li>Use Claude Desktop to ask questions about your courses</li> 145 159 <li>Works with any Canvas institution</li> 146 160 </ul> 147 161 </section> ··· 149 163 <section class="login-form"> 150 164 <h2>Get Started</h2> 151 165 <form id="loginForm"> 152 - <label for="canvasDomain">Canvas Domain</label> 153 - <input 154 - type="text" 155 - id="canvasDomain" 156 - name="canvasDomain" 157 - placeholder="canvas.university.edu" 158 - autocomplete="off" 159 - required 160 - /> 161 - 162 - <label for="accessToken" style="margin-top: 1rem;">Personal Access Token</label> 166 + <label for="email">Email Address</label> 163 167 <input 164 - type="password" 165 - id="accessToken" 166 - name="accessToken" 167 - placeholder="Get this from Canvas → Settings → New Access Token" 168 - autocomplete="off" 168 + type="email" 169 + id="email" 170 + name="email" 171 + placeholder="your.email@school.edu" 172 + autocomplete="email" 169 173 required 170 174 /> 171 175 172 - <button type="submit">Connect Canvas</button> 176 + <button type="submit">Send Sign-In Link</button> 173 177 <div id="error" class="error"></div> 178 + <div id="success" class="success"></div> 174 179 </form> 175 180 176 - <details style="margin-top: 1.5rem;"> 177 - <summary style="cursor: pointer; color: #666;">How to get a Personal Access Token</summary> 178 - <ol style="margin-top: 1rem; padding-left: 1.5rem; color: #666; font-size: 0.9rem;"> 179 - <li>Log in to your Canvas account</li> 180 - <li>Go to Account → Settings</li> 181 - <li>Scroll to "Approved Integrations"</li> 182 - <li>Click "+ New Access Token"</li> 183 - <li>Set Purpose: "MCP Server"</li> 184 - <li>Set Expires: (optional, max 120 days)</li> 185 - <li>Click "Generate Token"</li> 186 - <li>Copy the token and paste it above</li> 187 - </ol> 188 - </details> 181 + <p style="margin-top: 1.5rem; font-size: 0.9rem; color: #666;"> 182 + We'll send you a magic link to sign in. No password required. 183 + </p> 189 184 </section> 190 185 191 186 <footer> ··· 204 199 205 200 const form = document.getElementById('loginForm'); 206 201 const errorDiv = document.getElementById('error'); 202 + const successDiv = document.getElementById('success'); 207 203 208 204 function showError(message) { 209 205 errorDiv.textContent = message; 210 206 errorDiv.classList.add('show'); 207 + successDiv.classList.remove('show'); 211 208 } 212 209 213 - function hideError() { 210 + function showSuccess(message) { 211 + successDiv.textContent = message; 212 + successDiv.classList.add('show'); 213 + errorDiv.classList.remove('show'); 214 + } 215 + 216 + function hideMessages() { 214 217 errorDiv.classList.remove('show'); 218 + successDiv.classList.remove('show'); 215 219 } 216 220 217 221 form.addEventListener('submit', async (e) => { 218 222 e.preventDefault(); 219 - hideError(); 223 + hideMessages(); 220 224 221 - const domain = document.getElementById('canvasDomain').value.trim(); 222 - const token = document.getElementById('accessToken').value.trim(); 225 + const email = document.getElementById('email').value.trim(); 223 226 224 - if (!domain || !token) { 225 - showError('Please fill in all fields'); 227 + if (!email) { 228 + showError('Please enter your email address'); 226 229 return; 227 230 } 228 231 229 - if (!domain.includes('.')) { 230 - showError('Please enter a valid domain (e.g., canvas.university.edu)'); 232 + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 233 + showError('Please enter a valid email address'); 231 234 return; 232 235 } 233 236 234 237 const submitBtn = form.querySelector('button'); 235 238 submitBtn.disabled = true; 236 - submitBtn.textContent = 'Connecting...'; 239 + submitBtn.textContent = 'Sending...'; 237 240 238 241 try { 239 - const response = await fetch('/api/auth/token-login', { 242 + const response = await fetch('/api/auth/request-magic-link', { 240 243 method: 'POST', 241 244 headers: { 'Content-Type': 'application/json' }, 242 - body: JSON.stringify({ 243 - canvas_domain: domain, 244 - access_token: token 245 - }) 245 + body: JSON.stringify({ email }) 246 246 }); 247 247 248 248 const data = await response.json(); 249 249 250 250 if (!response.ok) { 251 - throw new Error(data.error || 'Failed to connect'); 251 + throw new Error(data.error || 'Failed to send magic link'); 252 252 } 253 253 254 - window.location.href = '/dashboard'; 254 + showSuccess(`Check your email! We sent a sign-in link to ${email}`); 255 + form.reset(); 255 256 } catch (error) { 256 257 showError(error.message); 258 + } finally { 257 259 submitBtn.disabled = false; 258 - submitBtn.textContent = 'Connect Canvas'; 260 + submitBtn.textContent = 'Send Sign-In Link'; 259 261 } 260 262 }); 261 263 </script>