a fancy canvas mcp server!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: expose the og image

+626 -572
+626 -572
src/index.ts
··· 2 2 import DB from "./lib/db.js"; 3 3 import { CanvasClient } from "./lib/canvas.js"; 4 4 import { 5 - handleMcpRequest, 6 - getProtectedResourceMetadata, 5 + handleMcpRequest, 6 + getProtectedResourceMetadata, 7 7 } from "./lib/mcp-transport.js"; 8 8 import Mailer from "./lib/email.js"; 9 9 ··· 12 12 import dashboardPage from "./public/dashboard.html"; 13 13 14 14 // Configuration 15 - const PORT = parseInt(process.env.PORT || "3000"); 15 + const PORT = parseInt(process.env.PORT || "3000", 10); 16 16 const HOST = process.env.HOST || "localhost"; 17 17 const BASE_URL = process.env.BASE_URL || `http://${HOST}:${PORT}`; 18 18 19 19 // Generate session cookie 20 20 function generateSessionId(): string { 21 - return randomBytes(32).toString("base64url"); 21 + return randomBytes(32).toString("base64url"); 22 22 } 23 23 24 24 // Get session from cookie 25 25 function getSession(req: Request) { 26 - const cookie = req.headers.get("cookie"); 27 - if (!cookie) return null; 26 + const cookie = req.headers.get("cookie"); 27 + if (!cookie) return null; 28 28 29 - const sessionCookie = cookie 30 - .split(";") 31 - .find((c) => c.trim().startsWith("session=")); 32 - if (!sessionCookie) return null; 29 + const sessionCookie = cookie 30 + .split(";") 31 + .find((c) => c.trim().startsWith("session=")); 32 + if (!sessionCookie) return null; 33 33 34 - const sessionId = sessionCookie.split("=")[1]; 35 - return DB.getSession(sessionId); 34 + const sessionId = sessionCookie.split("=")[1]; 35 + return DB.getSession(sessionId); 36 36 } 37 37 38 38 // Routes 39 39 const routes = { 40 - // Web pages 41 - "/": indexPage, 42 - "/dashboard": dashboardPage, 40 + // Web pages 41 + "/": indexPage, 42 + "/dashboard": dashboardPage, 43 43 44 - // Favicon 45 - "/favicon.ico": { 46 - GET() { 47 - const file = Bun.file("src/public/favicon.ico"); 48 - return new Response(file, { 49 - headers: { "Content-Type": "image/x-icon" }, 50 - }); 51 - }, 52 - }, 44 + // Favicon 45 + "/favicon.ico": { 46 + GET() { 47 + const file = Bun.file("src/public/favicon.ico"); 48 + return new Response(file, { 49 + headers: { "Content-Type": "image/x-icon" }, 50 + }); 51 + }, 52 + }, 53 + "/og.png": Bun.file("src/public/og.png"), 53 54 54 - // Health check endpoint 55 - "/health": { 56 - GET(req: Request) { 57 - const url = new URL(req.url); 58 - const detailed = url.searchParams.get("detailed") === "true"; 55 + // Health check endpoint 56 + "/health": { 57 + GET(req: Request) { 58 + const url = new URL(req.url); 59 + const detailed = url.searchParams.get("detailed") === "true"; 59 60 60 - if (detailed) { 61 - return Response.json({ 62 - status: "healthy", 63 - timestamp: new Date().toISOString(), 64 - version: "1.0.0", 65 - uptime: process.uptime(), 66 - cache: { 67 - apiKeys: DB.getApiKeyCacheStats(), 68 - canvas: CanvasClient.getCacheStats(), 69 - }, 70 - }); 71 - } 61 + if (detailed) { 62 + return Response.json({ 63 + status: "healthy", 64 + timestamp: new Date().toISOString(), 65 + version: "1.0.0", 66 + uptime: process.uptime(), 67 + cache: { 68 + apiKeys: DB.getApiKeyCacheStats(), 69 + canvas: CanvasClient.getCacheStats(), 70 + }, 71 + }); 72 + } 72 73 73 - return Response.json({ status: "healthy" }); 74 - }, 75 - }, 74 + return Response.json({ status: "healthy" }); 75 + }, 76 + }, 76 77 77 - // MCP Protocol endpoint (Streamable HTTP) 78 - "/mcp": { 79 - async POST(req: Request) { 80 - // Extract Bearer token from Authorization header 81 - const authHeader = req.headers.get("Authorization"); 82 - const token = authHeader?.startsWith("Bearer ") 83 - ? authHeader.slice(7) 84 - : undefined; 78 + // MCP Protocol endpoint (Streamable HTTP) 79 + "/mcp": { 80 + async POST(req: Request) { 81 + // Extract Bearer token from Authorization header 82 + const authHeader = req.headers.get("Authorization"); 83 + const token = authHeader?.startsWith("Bearer ") 84 + ? authHeader.slice(7) 85 + : undefined; 85 86 86 - return handleMcpRequest(req, token); 87 - }, 88 - }, 87 + return handleMcpRequest(req, token); 88 + }, 89 + }, 89 90 90 - // Protected Resource Metadata (OAuth discovery) 91 - "/.well-known/oauth-protected-resource": { 92 - GET() { 93 - return Response.json(getProtectedResourceMetadata(BASE_URL)); 94 - }, 95 - }, 91 + // Protected Resource Metadata (OAuth discovery) 92 + "/.well-known/oauth-protected-resource": { 93 + GET() { 94 + return Response.json(getProtectedResourceMetadata(BASE_URL)); 95 + }, 96 + }, 96 97 97 - // Protected Resource Metadata with MCP path 98 - "/.well-known/oauth-protected-resource/mcp": { 99 - GET() { 100 - return Response.json(getProtectedResourceMetadata(BASE_URL)); 101 - }, 102 - }, 98 + // Protected Resource Metadata with MCP path 99 + "/.well-known/oauth-protected-resource/mcp": { 100 + GET() { 101 + return Response.json(getProtectedResourceMetadata(BASE_URL)); 102 + }, 103 + }, 103 104 104 - // Authorization Server Metadata (at root for discovery) 105 - "/.well-known/oauth-authorization-server/auth": { 106 - GET() { 107 - return Response.json({ 108 - issuer: `${BASE_URL}/auth`, 109 - authorization_endpoint: `${BASE_URL}/auth/authorize`, 110 - token_endpoint: `${BASE_URL}/auth/token`, 111 - code_challenge_methods_supported: ["S256"], 112 - grant_types_supported: ["authorization_code", "refresh_token"], 113 - response_types_supported: ["code"], 114 - scopes_supported: [ 115 - "canvas:read", 116 - "canvas:courses:read", 117 - "canvas:assignments:read", 118 - "canvas:grades:read", 119 - "canvas:announcements:read", 120 - ], 121 - token_endpoint_auth_methods_supported: ["none"], 122 - client_id_metadata_document_supported: true, 123 - }); 124 - }, 125 - }, 105 + // Authorization Server Metadata (at root for discovery) 106 + "/.well-known/oauth-authorization-server/auth": { 107 + GET() { 108 + return Response.json({ 109 + issuer: `${BASE_URL}/auth`, 110 + authorization_endpoint: `${BASE_URL}/auth/authorize`, 111 + token_endpoint: `${BASE_URL}/auth/token`, 112 + code_challenge_methods_supported: ["S256"], 113 + grant_types_supported: ["authorization_code", "refresh_token"], 114 + response_types_supported: ["code"], 115 + scopes_supported: [ 116 + "canvas:read", 117 + "canvas:courses:read", 118 + "canvas:assignments:read", 119 + "canvas:grades:read", 120 + "canvas:announcements:read", 121 + ], 122 + token_endpoint_auth_methods_supported: ["none"], 123 + client_id_metadata_document_supported: true, 124 + }); 125 + }, 126 + }, 126 127 127 - // OpenID Connect Discovery (some clients look for this) 128 - "/.well-known/openid-configuration/auth": { 129 - GET() { 130 - return Response.json({ 131 - issuer: `${BASE_URL}/auth`, 132 - authorization_endpoint: `${BASE_URL}/auth/authorize`, 133 - token_endpoint: `${BASE_URL}/auth/token`, 134 - code_challenge_methods_supported: ["S256"], 135 - grant_types_supported: ["authorization_code", "refresh_token"], 136 - response_types_supported: ["code"], 137 - scopes_supported: [ 138 - "canvas:read", 139 - "canvas:courses:read", 140 - "canvas:assignments:read", 141 - "canvas:grades:read", 142 - "canvas:announcements:read", 143 - ], 144 - token_endpoint_auth_methods_supported: ["none"], 145 - }); 146 - }, 147 - }, 128 + // OpenID Connect Discovery (some clients look for this) 129 + "/.well-known/openid-configuration/auth": { 130 + GET() { 131 + return Response.json({ 132 + issuer: `${BASE_URL}/auth`, 133 + authorization_endpoint: `${BASE_URL}/auth/authorize`, 134 + token_endpoint: `${BASE_URL}/auth/token`, 135 + code_challenge_methods_supported: ["S256"], 136 + grant_types_supported: ["authorization_code", "refresh_token"], 137 + response_types_supported: ["code"], 138 + scopes_supported: [ 139 + "canvas:read", 140 + "canvas:courses:read", 141 + "canvas:assignments:read", 142 + "canvas:grades:read", 143 + "canvas:announcements:read", 144 + ], 145 + token_endpoint_auth_methods_supported: ["none"], 146 + }); 147 + }, 148 + }, 148 149 149 - "/auth/.well-known/openid-configuration": { 150 - GET() { 151 - return Response.json({ 152 - issuer: `${BASE_URL}/auth`, 153 - authorization_endpoint: `${BASE_URL}/auth/authorize`, 154 - token_endpoint: `${BASE_URL}/auth/token`, 155 - code_challenge_methods_supported: ["S256"], 156 - grant_types_supported: ["authorization_code", "refresh_token"], 157 - response_types_supported: ["code"], 158 - scopes_supported: [ 159 - "canvas:read", 160 - "canvas:courses:read", 161 - "canvas:assignments:read", 162 - "canvas:grades:read", 163 - "canvas:announcements:read", 164 - ], 165 - token_endpoint_auth_methods_supported: ["none"], 166 - }); 167 - }, 168 - }, 150 + "/auth/.well-known/openid-configuration": { 151 + GET() { 152 + return Response.json({ 153 + issuer: `${BASE_URL}/auth`, 154 + authorization_endpoint: `${BASE_URL}/auth/authorize`, 155 + token_endpoint: `${BASE_URL}/auth/token`, 156 + code_challenge_methods_supported: ["S256"], 157 + grant_types_supported: ["authorization_code", "refresh_token"], 158 + response_types_supported: ["code"], 159 + scopes_supported: [ 160 + "canvas:read", 161 + "canvas:courses:read", 162 + "canvas:assignments:read", 163 + "canvas:grades:read", 164 + "canvas:announcements:read", 165 + ], 166 + token_endpoint_auth_methods_supported: ["none"], 167 + }); 168 + }, 169 + }, 169 170 170 - // Dynamic client registration (return 501 Not Implemented for now) 171 - "/register": { 172 - POST() { 173 - return Response.json( 174 - { error: "dynamic_registration_not_supported", error_description: "Use Client ID Metadata Documents instead" }, 175 - { status: 501 } 176 - ); 177 - }, 178 - }, 171 + // Dynamic client registration (return 501 Not Implemented for now) 172 + "/register": { 173 + POST() { 174 + return Response.json( 175 + { 176 + error: "dynamic_registration_not_supported", 177 + error_description: "Use Client ID Metadata Documents instead", 178 + }, 179 + { status: 501 }, 180 + ); 181 + }, 182 + }, 179 183 180 - // OAuth authorization endpoint 181 - "/auth/authorize": { 182 - async GET(req: Request) { 183 - const url = new URL(req.url); 184 - const client_id = url.searchParams.get("client_id"); 185 - const redirect_uri = url.searchParams.get("redirect_uri"); 186 - const code_challenge = url.searchParams.get("code_challenge"); 187 - const code_challenge_method = url.searchParams.get("code_challenge_method"); 188 - const resource = url.searchParams.get("resource"); 189 - const scope = url.searchParams.get("scope") || "canvas:read"; 190 - const state = url.searchParams.get("state") || ""; 191 - const response_type = url.searchParams.get("response_type"); 184 + // OAuth authorization endpoint 185 + "/auth/authorize": { 186 + async GET(req: Request) { 187 + const url = new URL(req.url); 188 + const client_id = url.searchParams.get("client_id"); 189 + const redirect_uri = url.searchParams.get("redirect_uri"); 190 + const code_challenge = url.searchParams.get("code_challenge"); 191 + const code_challenge_method = url.searchParams.get( 192 + "code_challenge_method", 193 + ); 194 + const resource = url.searchParams.get("resource"); 195 + const scope = url.searchParams.get("scope") || "canvas:read"; 196 + const state = url.searchParams.get("state") || ""; 197 + const response_type = url.searchParams.get("response_type"); 192 198 193 - // Validate required parameters 194 - if (!client_id || !redirect_uri || !code_challenge || !response_type) { 195 - return new Response("Missing required OAuth parameters", { status: 400 }); 196 - } 199 + // Validate required parameters 200 + if (!client_id || !redirect_uri || !code_challenge || !response_type) { 201 + return new Response("Missing required OAuth parameters", { 202 + status: 400, 203 + }); 204 + } 197 205 198 - if (response_type !== "code") { 199 - return new Response("Only authorization_code flow is supported", { status: 400 }); 200 - } 206 + if (response_type !== "code") { 207 + return new Response("Only authorization_code flow is supported", { 208 + status: 400, 209 + }); 210 + } 201 211 202 - if (code_challenge_method !== "S256") { 203 - return new Response("Only S256 PKCE method is supported", { status: 400 }); 204 - } 212 + if (code_challenge_method !== "S256") { 213 + return new Response("Only S256 PKCE method is supported", { 214 + status: 400, 215 + }); 216 + } 205 217 206 - // Check if user is logged in 207 - const session = getSession(req); 208 - if (!session?.user_id) { 209 - // Redirect to login, preserving OAuth params 210 - return new Response(null, { 211 - status: 302, 212 - headers: { 213 - Location: `/?oauth_redirect=${encodeURIComponent(req.url)}`, 214 - }, 215 - }); 216 - } 218 + // Check if user is logged in 219 + const session = getSession(req); 220 + if (!session?.user_id) { 221 + // Redirect to login, preserving OAuth params 222 + return new Response(null, { 223 + status: 302, 224 + headers: { 225 + Location: `/?oauth_redirect=${encodeURIComponent(req.url)}`, 226 + }, 227 + }); 228 + } 217 229 218 - // Check if user has Canvas connected 219 - const user = DB.raw 220 - .query("SELECT * FROM users WHERE id = ?") 221 - .get(session.user_id) as any; 230 + // Check if user has Canvas connected 231 + const user = DB.raw 232 + .query("SELECT * FROM users WHERE id = ?") 233 + .get(session.user_id) as any; 222 234 223 - if (!user || !user.canvas_domain) { 224 - return new Response(` 235 + if (!user || !user.canvas_domain) { 236 + return new Response( 237 + ` 225 238 <!DOCTYPE html> 226 239 <html lang="en"> 227 240 <head> ··· 256 269 <p><a href="/dashboard">Go to Dashboard →</a></p> 257 270 </main> 258 271 </body> 259 - </html>`, { headers: { "Content-Type": "text/html" }}); 260 - } 272 + </html>`, 273 + { headers: { "Content-Type": "text/html" } }, 274 + ); 275 + } 261 276 262 - // Show consent page 263 - const consentHTML = ` 277 + // Show consent page 278 + const consentHTML = ` 264 279 <!DOCTYPE html> 265 280 <html lang="en"> 266 281 <head> ··· 315 330 <body> 316 331 <main> 317 332 <h1>Authorize Access</h1> 318 - <p><strong>${client_id.split('/')[2]}</strong> wants to access your Canvas data.</p> 333 + <p><strong>${client_id.split("/")[2]}</strong> wants to access your Canvas data.</p> 319 334 320 335 <div class="scopes"> 321 336 <strong>Requested permissions:</strong> 322 - ${scope.split(" ").map(s => ` 337 + ${scope 338 + .split(" ") 339 + .map( 340 + (s) => ` 323 341 <div class="scope-item"> 324 - ${s.replace("canvas:", "").replace(/:read$/, "").replace(/_/g, " ")} 342 + ${s 343 + .replace("canvas:", "") 344 + .replace(/:read$/, "") 345 + .replace(/_/g, " ")} 325 346 </div> 326 - `).join("")} 347 + `, 348 + ) 349 + .join("")} 327 350 </div> 328 351 329 352 <form method="POST" action="/auth/consent"> ··· 343 366 </body> 344 367 </html>`; 345 368 346 - return new Response(consentHTML, { 347 - headers: { "Content-Type": "text/html" }, 348 - }); 349 - }, 350 - }, 369 + return new Response(consentHTML, { 370 + headers: { "Content-Type": "text/html" }, 371 + }); 372 + }, 373 + }, 351 374 352 - // OAuth consent handler 353 - "/auth/consent": { 354 - async POST(req: Request) { 355 - const session = getSession(req); 356 - if (!session?.user_id) { 357 - return Response.json({ error: "Not authenticated" }, { status: 401 }); 358 - } 375 + // OAuth consent handler 376 + "/auth/consent": { 377 + async POST(req: Request) { 378 + const session = getSession(req); 379 + if (!session?.user_id) { 380 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 381 + } 359 382 360 - const formData = await req.formData(); 361 - const action = formData.get("action"); 362 - const client_id = formData.get("client_id") as string; 363 - const redirect_uri = formData.get("redirect_uri") as string; 364 - const code_challenge = formData.get("code_challenge") as string; 365 - const code_challenge_method = formData.get("code_challenge_method") as string; 366 - const scope = formData.get("scope") as string; 367 - const state = formData.get("state") as string; 383 + const formData = await req.formData(); 384 + const action = formData.get("action"); 385 + const client_id = formData.get("client_id") as string; 386 + const redirect_uri = formData.get("redirect_uri") as string; 387 + const code_challenge = formData.get("code_challenge") as string; 388 + const code_challenge_method = formData.get( 389 + "code_challenge_method", 390 + ) as string; 391 + const scope = formData.get("scope") as string; 392 + const state = formData.get("state") as string; 368 393 369 - if (action === "deny") { 370 - return new Response(null, { 371 - status: 302, 372 - headers: { 373 - Location: `${redirect_uri}?error=access_denied&state=${state}`, 374 - }, 375 - }); 376 - } 394 + if (action === "deny") { 395 + return new Response(null, { 396 + status: 302, 397 + headers: { 398 + Location: `${redirect_uri}?error=access_denied&state=${state}`, 399 + }, 400 + }); 401 + } 377 402 378 - // Generate authorization code 379 - const authCode = randomBytes(32).toString("base64url"); 403 + // Generate authorization code 404 + const authCode = randomBytes(32).toString("base64url"); 380 405 381 - // Store auth code in database 382 - DB.raw.run( 383 - `INSERT INTO auth_codes (code, user_id, client_id, redirect_uri, code_challenge, code_challenge_method, scope, expires_at) 406 + // Store auth code in database 407 + DB.raw.run( 408 + `INSERT INTO auth_codes (code, user_id, client_id, redirect_uri, code_challenge, code_challenge_method, scope, expires_at) 384 409 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 385 - [ 386 - authCode, 387 - session.user_id, 388 - client_id, 389 - redirect_uri, 390 - code_challenge, 391 - code_challenge_method, 392 - scope, 393 - Date.now() + 10 * 60 * 1000, // 10 minutes 394 - ] 395 - ); 410 + [ 411 + authCode, 412 + session.user_id, 413 + client_id, 414 + redirect_uri, 415 + code_challenge, 416 + code_challenge_method, 417 + scope, 418 + Date.now() + 10 * 60 * 1000, // 10 minutes 419 + ], 420 + ); 396 421 397 - // Redirect back with code 398 - return new Response(null, { 399 - status: 302, 400 - headers: { 401 - Location: `${redirect_uri}?code=${authCode}&state=${state}`, 402 - }, 403 - }); 404 - }, 405 - }, 422 + // Redirect back with code 423 + return new Response(null, { 424 + status: 302, 425 + headers: { 426 + Location: `${redirect_uri}?code=${authCode}&state=${state}`, 427 + }, 428 + }); 429 + }, 430 + }, 406 431 407 - // OAuth token endpoint 408 - "/auth/token": { 409 - async POST(req: Request) { 410 - // OAuth 2.0 token requests use application/x-www-form-urlencoded 411 - const contentType = req.headers.get("content-type") || ""; 412 - let grant_type, code, redirect_uri, code_verifier, client_id, resource; 432 + // OAuth token endpoint 433 + "/auth/token": { 434 + async POST(req: Request) { 435 + // OAuth 2.0 token requests use application/x-www-form-urlencoded 436 + const contentType = req.headers.get("content-type") || ""; 437 + let grant_type, code, redirect_uri, code_verifier, client_id, resource; 413 438 414 - if (contentType.includes("application/x-www-form-urlencoded")) { 415 - const formData = await req.formData(); 416 - grant_type = formData.get("grant_type") as string; 417 - code = formData.get("code") as string; 418 - redirect_uri = formData.get("redirect_uri") as string; 419 - code_verifier = formData.get("code_verifier") as string; 420 - client_id = formData.get("client_id") as string; 421 - resource = formData.get("resource") as string; 422 - } else { 423 - // Fall back to JSON for backwards compatibility 424 - const body = await req.json(); 425 - ({ grant_type, code, redirect_uri, code_verifier, client_id, resource } = body); 426 - } 439 + if (contentType.includes("application/x-www-form-urlencoded")) { 440 + const formData = await req.formData(); 441 + grant_type = formData.get("grant_type") as string; 442 + code = formData.get("code") as string; 443 + redirect_uri = formData.get("redirect_uri") as string; 444 + code_verifier = formData.get("code_verifier") as string; 445 + client_id = formData.get("client_id") as string; 446 + resource = formData.get("resource") as string; 447 + } else { 448 + // Fall back to JSON for backwards compatibility 449 + const body = await req.json(); 450 + ({ 451 + grant_type, 452 + code, 453 + redirect_uri, 454 + code_verifier, 455 + client_id, 456 + resource, 457 + } = body); 458 + } 427 459 428 - if (grant_type !== "authorization_code") { 429 - return Response.json( 430 - { error: "unsupported_grant_type" }, 431 - { status: 400 } 432 - ); 433 - } 460 + if (grant_type !== "authorization_code") { 461 + return Response.json( 462 + { error: "unsupported_grant_type" }, 463 + { status: 400 }, 464 + ); 465 + } 434 466 435 - if (!code || !code_verifier || !client_id) { 436 - return Response.json( 437 - { error: "invalid_request", error_description: "Missing required parameters" }, 438 - { status: 400 } 439 - ); 440 - } 467 + if (!code || !code_verifier || !client_id) { 468 + return Response.json( 469 + { 470 + error: "invalid_request", 471 + error_description: "Missing required parameters", 472 + }, 473 + { status: 400 }, 474 + ); 475 + } 441 476 442 - // Look up auth code 443 - const authData = DB.raw 444 - .query("SELECT * FROM auth_codes WHERE code = ? AND expires_at > ?") 445 - .get(code, Date.now()) as any; 477 + // Look up auth code 478 + const authData = DB.raw 479 + .query("SELECT * FROM auth_codes WHERE code = ? AND expires_at > ?") 480 + .get(code, Date.now()) as any; 446 481 447 - if (!authData) { 448 - return Response.json( 449 - { error: "invalid_grant", error_description: "Invalid or expired authorization code" }, 450 - { status: 400 } 451 - ); 452 - } 482 + if (!authData) { 483 + return Response.json( 484 + { 485 + error: "invalid_grant", 486 + error_description: "Invalid or expired authorization code", 487 + }, 488 + { status: 400 }, 489 + ); 490 + } 453 491 454 - // Verify PKCE 455 - const hash = require("crypto").createHash("sha256").update(code_verifier).digest("base64url"); 456 - if (hash !== authData.code_challenge) { 457 - DB.raw.run("DELETE FROM auth_codes WHERE code = ?", [code]); 458 - return Response.json( 459 - { error: "invalid_grant", error_description: "PKCE validation failed" }, 460 - { status: 400 } 461 - ); 462 - } 492 + // Verify PKCE 493 + const hash = require("crypto") 494 + .createHash("sha256") 495 + .update(code_verifier) 496 + .digest("base64url"); 497 + if (hash !== authData.code_challenge) { 498 + DB.raw.run("DELETE FROM auth_codes WHERE code = ?", [code]); 499 + return Response.json( 500 + { 501 + error: "invalid_grant", 502 + error_description: "PKCE validation failed", 503 + }, 504 + { status: 400 }, 505 + ); 506 + } 463 507 464 - // Verify client_id and redirect_uri match 465 - if (client_id !== authData.client_id || redirect_uri !== authData.redirect_uri) { 466 - return Response.json( 467 - { error: "invalid_grant", error_description: "Client ID or redirect URI mismatch" }, 468 - { status: 400 } 469 - ); 470 - } 508 + // Verify client_id and redirect_uri match 509 + if ( 510 + client_id !== authData.client_id || 511 + redirect_uri !== authData.redirect_uri 512 + ) { 513 + return Response.json( 514 + { 515 + error: "invalid_grant", 516 + error_description: "Client ID or redirect URI mismatch", 517 + }, 518 + { status: 400 }, 519 + ); 520 + } 471 521 472 - // Generate OAuth access token 473 - const accessToken = DB.createOAuthToken(authData.user_id, authData.scope, 86400000); 522 + // Generate OAuth access token 523 + const accessToken = DB.createOAuthToken( 524 + authData.user_id, 525 + authData.scope, 526 + 86400000, 527 + ); 474 528 475 - // Delete used auth code 476 - DB.raw.run("DELETE FROM auth_codes WHERE code = ?", [code]); 529 + // Delete used auth code 530 + DB.raw.run("DELETE FROM auth_codes WHERE code = ?", [code]); 477 531 478 - return Response.json({ 479 - access_token: accessToken, 480 - token_type: "Bearer", 481 - expires_in: 86400, // 24 hours 482 - scope: authData.scope, 483 - }); 484 - }, 485 - }, 532 + return Response.json({ 533 + access_token: accessToken, 534 + token_type: "Bearer", 535 + expires_in: 86400, // 24 hours 536 + scope: authData.scope, 537 + }); 538 + }, 539 + }, 486 540 487 - // Auth endpoints 488 - "/api/auth/token-login": { 489 - async POST(req: Request) { 490 - try { 491 - const { canvas_domain, access_token } = await req.json(); 541 + // Auth endpoints 542 + "/api/auth/token-login": { 543 + async POST(req: Request) { 544 + try { 545 + const { canvas_domain, access_token } = await req.json(); 492 546 493 - if (!canvas_domain || !access_token) { 494 - return Response.json( 495 - { error: "Canvas domain and access token are required" }, 496 - { status: 400 }, 497 - ); 498 - } 547 + if (!canvas_domain || !access_token) { 548 + return Response.json( 549 + { error: "Canvas domain and access token are required" }, 550 + { status: 400 }, 551 + ); 552 + } 499 553 500 - // Verify the token by making a test API call 501 - const client = new CanvasClient(canvas_domain, access_token); 554 + // Verify the token by making a test API call 555 + const client = new CanvasClient(canvas_domain, access_token); 502 556 503 - let canvasUser; 504 - try { 505 - canvasUser = await client.getCurrentUser(); 506 - } catch (error: any) { 507 - return Response.json( 508 - { 509 - error: 510 - "Invalid access token or Canvas domain. Please check your credentials and try again.", 511 - }, 512 - { status: 401 }, 513 - ); 514 - } 557 + let canvasUser; 558 + try { 559 + canvasUser = await client.getCurrentUser(); 560 + } catch (error: any) { 561 + return Response.json( 562 + { 563 + error: 564 + "Invalid access token or Canvas domain. Please check your credentials and try again.", 565 + }, 566 + { status: 401 }, 567 + ); 568 + } 515 569 516 - // Check if user is already logged in (via magic link) 517 - const session = getSession(req); 518 - if (session?.user_id) { 519 - // Update existing magic link user with Canvas credentials 520 - const { apiKey } = await DB.updateUserCanvas( 521 - session.user_id, 522 - canvasUser.id.toString(), 523 - canvas_domain, 524 - access_token 525 - ); 570 + // Check if user is already logged in (via magic link) 571 + const session = getSession(req); 572 + if (session?.user_id) { 573 + // Update existing magic link user with Canvas credentials 574 + const { apiKey } = await DB.updateUserCanvas( 575 + session.user_id, 576 + canvasUser.id.toString(), 577 + canvas_domain, 578 + access_token, 579 + ); 526 580 527 - // Store API key in session if just generated (so user can see it) 528 - if (apiKey) { 529 - DB.updateSession(session.id, { api_key: apiKey }); 530 - } 581 + // Store API key in session if just generated (so user can see it) 582 + if (apiKey) { 583 + DB.updateSession(session.id, { api_key: apiKey }); 584 + } 531 585 532 - return Response.json({ success: true }); 533 - } 586 + return Response.json({ success: true }); 587 + } 534 588 535 - // Create or update user 536 - const { user, apiKey, isNewUser } = await DB.createOrUpdateUser({ 537 - canvas_user_id: canvasUser.id.toString(), 538 - canvas_domain, 539 - email: canvasUser.primary_email || canvasUser.login_id, 540 - canvas_access_token: access_token, 541 - }); 589 + // Create or update user 590 + const { user, apiKey, isNewUser } = await DB.createOrUpdateUser({ 591 + canvas_user_id: canvasUser.id.toString(), 592 + canvas_domain, 593 + email: canvasUser.primary_email || canvasUser.login_id, 594 + canvas_access_token: access_token, 595 + }); 542 596 543 - // Create session with MCP token (only for new users) 544 - const sessionId = generateSessionId(); 545 - DB.createSession(sessionId, { 546 - canvas_domain, 547 - state: "", 548 - user_id: user.id, 549 - api_key: isNewUser ? apiKey : undefined, 550 - maxAge: 2592000, // 30 days 551 - }); 597 + // Create session with MCP token (only for new users) 598 + const sessionId = generateSessionId(); 599 + DB.createSession(sessionId, { 600 + canvas_domain, 601 + state: "", 602 + user_id: user.id, 603 + api_key: isNewUser ? apiKey : undefined, 604 + maxAge: 2592000, // 30 days 605 + }); 552 606 553 - return Response.json( 554 - { success: true }, 555 - { 556 - headers: { 557 - "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax${ 558 - BASE_URL.startsWith("https") ? "; Secure" : "" 559 - }`, 560 - }, 561 - }, 562 - ); 563 - } catch (error: any) { 564 - console.error("Token login error:", error); 565 - return Response.json( 566 - { error: error.message || "Login failed" }, 567 - { status: 500 }, 568 - ); 569 - } 570 - }, 571 - }, 607 + return Response.json( 608 + { success: true }, 609 + { 610 + headers: { 611 + "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax${ 612 + BASE_URL.startsWith("https") ? "; Secure" : "" 613 + }`, 614 + }, 615 + }, 616 + ); 617 + } catch (error: any) { 618 + console.error("Token login error:", error); 619 + return Response.json( 620 + { error: error.message || "Login failed" }, 621 + { status: 500 }, 622 + ); 623 + } 624 + }, 625 + }, 572 626 573 - "/api/auth/logout": { 574 - async POST(req: Request) { 575 - const session = getSession(req); 576 - if (session) { 577 - DB.deleteSession(session.id); 578 - } 627 + "/api/auth/logout": { 628 + async POST(req: Request) { 629 + const session = getSession(req); 630 + if (session) { 631 + DB.deleteSession(session.id); 632 + } 579 633 580 - return Response.json( 581 - { success: true }, 582 - { 583 - headers: { 584 - "Set-Cookie": "session=; HttpOnly; Path=/; Max-Age=0", 585 - }, 586 - }, 587 - ); 588 - }, 589 - }, 634 + return Response.json( 635 + { success: true }, 636 + { 637 + headers: { 638 + "Set-Cookie": "session=; HttpOnly; Path=/; Max-Age=0", 639 + }, 640 + }, 641 + ); 642 + }, 643 + }, 590 644 591 - // Magic link authentication 592 - "/api/auth/request-magic-link": { 593 - async POST(req: Request) { 594 - try { 595 - const { email } = await req.json(); 645 + // Magic link authentication 646 + "/api/auth/request-magic-link": { 647 + async POST(req: Request) { 648 + try { 649 + const { email } = await req.json(); 596 650 597 - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 598 - return Response.json( 599 - { error: "Valid email is required" }, 600 - { status: 400 } 601 - ); 602 - } 651 + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 652 + return Response.json( 653 + { error: "Valid email is required" }, 654 + { status: 400 }, 655 + ); 656 + } 603 657 604 - // Rate limiting: 1 email per minute per address 605 - const cooldownMs = 60 * 1000; // 1 minute 606 - if (!DB.canSendMagicLink(email, cooldownMs)) { 607 - const lastSent = DB.getLastMagicLinkTime(email); 608 - const waitTime = lastSent 609 - ? Math.ceil((lastSent + cooldownMs - Date.now()) / 1000) 610 - : 60; 658 + // Rate limiting: 1 email per minute per address 659 + const cooldownMs = 60 * 1000; // 1 minute 660 + if (!DB.canSendMagicLink(email, cooldownMs)) { 661 + const lastSent = DB.getLastMagicLinkTime(email); 662 + const waitTime = lastSent 663 + ? Math.ceil((lastSent + cooldownMs - Date.now()) / 1000) 664 + : 60; 611 665 612 - return Response.json( 613 - { 614 - error: `Please wait ${waitTime} seconds before requesting another link`, 615 - }, 616 - { status: 429 } 617 - ); 618 - } 666 + return Response.json( 667 + { 668 + error: `Please wait ${waitTime} seconds before requesting another link`, 669 + }, 670 + { status: 429 }, 671 + ); 672 + } 619 673 620 - // Generate magic link token 621 - const token = randomBytes(32).toString("base64url"); 622 - const expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes 674 + // Generate magic link token 675 + const token = randomBytes(32).toString("base64url"); 676 + const expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes 623 677 624 - // Store magic link 625 - DB.createMagicLink(email, token, expiresAt); 678 + // Store magic link 679 + DB.createMagicLink(email, token, expiresAt); 626 680 627 - // Send email 628 - try { 629 - await Mailer.sendMagicLink(email, token); 630 - } catch (error: any) { 631 - console.error("Failed to send magic link email:", error); 632 - return Response.json( 633 - { error: "Failed to send email. Please try again." }, 634 - { status: 500 } 635 - ); 636 - } 681 + // Send email 682 + try { 683 + await Mailer.sendMagicLink(email, token); 684 + } catch (error: any) { 685 + console.error("Failed to send magic link email:", error); 686 + return Response.json( 687 + { error: "Failed to send email. Please try again." }, 688 + { status: 500 }, 689 + ); 690 + } 637 691 638 - return Response.json({ 639 - success: true, 640 - message: "Check your email for a sign-in link", 641 - }); 642 - } catch (error: any) { 643 - console.error("Magic link error:", error); 644 - return Response.json( 645 - { error: "Failed to send magic link" }, 646 - { status: 500 } 647 - ); 648 - } 649 - }, 650 - }, 692 + return Response.json({ 693 + success: true, 694 + message: "Check your email for a sign-in link", 695 + }); 696 + } catch (error: any) { 697 + console.error("Magic link error:", error); 698 + return Response.json( 699 + { error: "Failed to send magic link" }, 700 + { status: 500 }, 701 + ); 702 + } 703 + }, 704 + }, 651 705 652 - "/auth/verify": { 653 - async GET(req: Request) { 654 - const url = new URL(req.url); 655 - const token = url.searchParams.get("token"); 706 + "/auth/verify": { 707 + async GET(req: Request) { 708 + const url = new URL(req.url); 709 + const token = url.searchParams.get("token"); 656 710 657 - if (!token) { 658 - return new Response("Missing token", { status: 400 }); 659 - } 711 + if (!token) { 712 + return new Response("Missing token", { status: 400 }); 713 + } 660 714 661 - const magicLink = DB.getMagicLink(token); 662 - if (!magicLink) { 663 - return new Response( 664 - `<!DOCTYPE html> 715 + const magicLink = DB.getMagicLink(token); 716 + if (!magicLink) { 717 + return new Response( 718 + `<!DOCTYPE html> 665 719 <html lang="en"> 666 720 <head> 667 721 <meta charset="UTF-8"> ··· 712 766 </main> 713 767 </body> 714 768 </html>`, 715 - { headers: { "Content-Type": "text/html" } } 716 - ); 717 - } 769 + { headers: { "Content-Type": "text/html" } }, 770 + ); 771 + } 718 772 719 - // Mark as used 720 - DB.markMagicLinkUsed(token); 773 + // Mark as used 774 + DB.markMagicLinkUsed(token); 721 775 722 - // Check if user exists 723 - let user = DB.getUserByEmail(magicLink.email); 776 + // Check if user exists 777 + let user = DB.getUserByEmail(magicLink.email); 724 778 725 - // If no user, create a placeholder (they'll add Canvas later) 726 - if (!user) { 727 - const result = DB.raw 728 - .prepare( 729 - "INSERT INTO users (email) VALUES (?)" 730 - ) 731 - .run(magicLink.email); 732 - user = { id: Number(result.lastInsertRowid), email: magicLink.email }; 733 - } 779 + // If no user, create a placeholder (they'll add Canvas later) 780 + if (!user) { 781 + const result = DB.raw 782 + .prepare("INSERT INTO users (email) VALUES (?)") 783 + .run(magicLink.email); 784 + user = { id: Number(result.lastInsertRowid), email: magicLink.email }; 785 + } 734 786 735 - // Create session 736 - const sessionId = generateSessionId(); 737 - DB.createSession(sessionId, { 738 - canvas_domain: user.canvas_domain || "", 739 - state: "", 740 - user_id: user.id, 741 - maxAge: 2592000, // 30 days 742 - }); 787 + // Create session 788 + const sessionId = generateSessionId(); 789 + DB.createSession(sessionId, { 790 + canvas_domain: user.canvas_domain || "", 791 + state: "", 792 + user_id: user.id, 793 + maxAge: 2592000, // 30 days 794 + }); 743 795 744 - return new Response(null, { 745 - status: 302, 746 - headers: { 747 - Location: "/dashboard", 748 - "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax${ 749 - BASE_URL.startsWith("https") ? "; Secure" : "" 750 - }`, 751 - }, 752 - }); 753 - }, 754 - }, 796 + return new Response(null, { 797 + status: 302, 798 + headers: { 799 + Location: "/dashboard", 800 + "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax${ 801 + BASE_URL.startsWith("https") ? "; Secure" : "" 802 + }`, 803 + }, 804 + }); 805 + }, 806 + }, 755 807 756 - // User endpoints 757 - "/api/user/me": { 758 - async GET(req: Request) { 759 - const session = getSession(req); 760 - if (!session?.user_id) { 761 - return Response.json({ error: "Not authenticated" }, { status: 401 }); 762 - } 808 + // User endpoints 809 + "/api/user/me": { 810 + async GET(req: Request) { 811 + const session = getSession(req); 812 + if (!session?.user_id) { 813 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 814 + } 763 815 764 - const userData = DB.raw 765 - .query("SELECT * FROM users WHERE id = ?") 766 - .get(session.user_id) as any; 816 + const userData = DB.raw 817 + .query("SELECT * FROM users WHERE id = ?") 818 + .get(session.user_id) as any; 767 819 768 - if (!userData) { 769 - return Response.json({ error: "User not found" }, { status: 404 }); 770 - } 820 + if (!userData) { 821 + return Response.json({ error: "User not found" }, { status: 404 }); 822 + } 771 823 772 - // Get usage stats 773 - const allUsage = DB.getUsageStats(userData.id); 774 - const last24h = DB.getUsageStats( 775 - userData.id, 776 - Date.now() - 24 * 60 * 60 * 1000, 777 - ); 778 - const last7d = DB.getUsageStats( 779 - userData.id, 780 - Date.now() - 7 * 24 * 60 * 60 * 1000, 781 - ); 824 + // Get usage stats 825 + const allUsage = DB.getUsageStats(userData.id); 826 + const last24h = DB.getUsageStats( 827 + userData.id, 828 + Date.now() - 24 * 60 * 60 * 1000, 829 + ); 830 + const last7d = DB.getUsageStats( 831 + userData.id, 832 + Date.now() - 7 * 24 * 60 * 60 * 1000, 833 + ); 782 834 783 - // Get MCP token from session (if just created) or hide it 784 - const apiKey = session.api_key || null; 835 + // Get MCP token from session (if just created) or hide it 836 + const apiKey = session.api_key || null; 785 837 786 - // Clear token from session after first view 787 - if (session.api_key) { 788 - DB.clearApiKeyFromSession(session.id); 789 - } 838 + // Clear token from session after first view 839 + if (session.api_key) { 840 + DB.clearApiKeyFromSession(session.id); 841 + } 790 842 791 - return Response.json({ 792 - canvas_domain: userData.canvas_domain, 793 - email: userData.email, 794 - created_at: userData.created_at, 795 - last_used_at: userData.last_used_at, 796 - api_key: apiKey, 797 - usage_stats: { 798 - total_requests: allUsage.length, 799 - requests_24h: last24h.length, 800 - requests_7d: last7d.length, 801 - }, 802 - }); 803 - }, 804 - }, 843 + return Response.json({ 844 + canvas_domain: userData.canvas_domain, 845 + email: userData.email, 846 + created_at: userData.created_at, 847 + last_used_at: userData.last_used_at, 848 + api_key: apiKey, 849 + usage_stats: { 850 + total_requests: allUsage.length, 851 + requests_24h: last24h.length, 852 + requests_7d: last7d.length, 853 + }, 854 + }); 855 + }, 856 + }, 805 857 806 - "/api/user/regenerate-key": { 807 - async POST(req: Request) { 808 - const session = getSession(req); 809 - if (!session?.user_id) { 810 - return Response.json({ error: "Not authenticated" }, { status: 401 }); 811 - } 858 + "/api/user/regenerate-key": { 859 + async POST(req: Request) { 860 + const session = getSession(req); 861 + if (!session?.user_id) { 862 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 863 + } 812 864 813 - const newApiKey = await DB.regenerateApiKey(session.user_id); 865 + const newApiKey = await DB.regenerateApiKey(session.user_id); 814 866 815 - return Response.json({ api_key: newApiKey }); 816 - }, 817 - }, 867 + return Response.json({ api_key: newApiKey }); 868 + }, 869 + }, 818 870 819 - // Admin endpoint to manually trigger cleanup 820 - "/api/admin/cleanup": { 821 - POST(req: Request) { 822 - const results = DB.runAllCleanups(); 823 - return Response.json({ 824 - success: true, 825 - removed: results, 826 - timestamp: new Date().toISOString(), 827 - }); 828 - }, 829 - }, 871 + // Admin endpoint to manually trigger cleanup 872 + "/api/admin/cleanup": { 873 + POST(req: Request) { 874 + const results = DB.runAllCleanups(); 875 + return Response.json({ 876 + success: true, 877 + removed: results, 878 + timestamp: new Date().toISOString(), 879 + }); 880 + }, 881 + }, 830 882 }; 831 883 832 884 // Start server 833 885 const server = Bun.serve({ 834 - port: PORT, 835 - routes, 836 - development: Bun.env.NODE_ENV !== "production", 886 + port: PORT, 887 + routes, 888 + development: Bun.env.NODE_ENV !== "production", 837 889 838 - fetch(req) { 839 - console.log(`${req.method} ${new URL(req.url).pathname}`); 840 - return new Response("Not Found", { status: 404 }); 841 - }, 890 + fetch(req) { 891 + console.log(`${req.method} ${new URL(req.url).pathname}`); 892 + return new Response("Not Found", { status: 404 }); 893 + }, 842 894 }); 843 895 844 896 console.log(`Canvas MCP Server running at ${BASE_URL}`); ··· 848 900 // Background cleanup job - runs every 5 minutes 849 901 const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes 850 902 851 - console.log(`[Cleanup] Starting background cleanup job (interval: ${CLEANUP_INTERVAL / 1000}s)`); 903 + console.log( 904 + `[Cleanup] Starting background cleanup job (interval: ${CLEANUP_INTERVAL / 1000}s)`, 905 + ); 852 906 853 907 // Run initial cleanup on startup 854 908 DB.runAllCleanups(); 855 909 856 910 // Schedule periodic cleanup 857 911 setInterval(() => { 858 - DB.runAllCleanups(); 912 + DB.runAllCleanups(); 859 913 }, CLEANUP_INTERVAL);