a fancy canvas mcp server!
1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 <title>Dashboard - Canvas MCP</title>
8 <meta name="description"
9 content="Manage your Canvas MCP Server connection and API credentials. Connect your Canvas LMS to AI assistants.">
10 <link rel="icon" type="image/x-icon" href="./favicon.ico">
11 <link rel="canonical" href="https://canvas.dunkirk.sh/dashboard" id="canonical-url">
12 <meta name="theme-color" content="#0066cc">
13
14 <!-- Open Graph / Facebook -->
15 <meta property="og:type" content="website">
16 <meta property="og:url" content="https://canvas.dunkirk.sh/dashboard" id="og-url">
17 <meta property="og:title" content="Dashboard - Canvas MCP">
18 <meta property="og:site_name" content="Canvas MCP Server">
19 <meta property="og:description"
20 content="Manage your Canvas MCP Server connection and API credentials. Connect your Canvas LMS to AI assistants.">
21 <meta property="og:image" content="https://canvas.dunkirk.sh/og.png" id="og-image">
22 <meta property="og:image:width" content="1200">
23 <meta property="og:image:height" content="630">
24 <meta property="og:image:alt" content="Canvas MCP Server - Connect Canvas LMS to AI assistants">
25
26 <!-- Twitter -->
27 <meta property="twitter:card" content="summary_large_image">
28 <meta property="twitter:url" content="https://canvas.dunkirk.sh/dashboard" id="twitter-url">
29 <meta property="twitter:title" content="Dashboard - Canvas MCP">
30 <meta property="twitter:description"
31 content="Manage your Canvas MCP Server connection and API credentials. Connect your Canvas LMS to AI assistants.">
32 <meta property="twitter:image" content="https://canvas.dunkirk.sh/og.png" id="twitter-image">
33
34 <script>
35 // Set dynamic URLs based on current host
36 const baseUrl = window.location.origin;
37 document.getElementById('canonical-url').setAttribute('href', `${baseUrl}/dashboard`);
38 document.getElementById('og-url').setAttribute('content', `${baseUrl}/dashboard`);
39 document.getElementById('og-image').setAttribute('content', `${baseUrl}/og.png`);
40 document.getElementById('twitter-url').setAttribute('content', `${baseUrl}/dashboard`);
41 document.getElementById('twitter-image').setAttribute('content', `${baseUrl}/og.png`);
42 </script>
43 <style>
44 * {
45 margin: 0;
46 padding: 0;
47 box-sizing: border-box;
48 }
49
50 body {
51 font-family: system-ui, -apple-system, sans-serif;
52 line-height: 1.6;
53 max-width: 900px;
54 margin: 2rem auto;
55 padding: 2rem;
56 color: #111;
57 }
58
59 header {
60 display: flex;
61 justify-content: space-between;
62 align-items: center;
63 margin-bottom: 2rem;
64 padding-bottom: 1rem;
65 border-bottom: 1px solid #ddd;
66 }
67
68 h1 {
69 font-size: 1.75rem;
70 font-weight: 600;
71 }
72
73 button {
74 padding: 0.5rem 1rem;
75 background: #333;
76 color: white;
77 border: none;
78 border-radius: 4px;
79 font-size: 0.9rem;
80 cursor: pointer;
81 }
82
83 button:hover {
84 background: #111;
85 }
86
87 .logout-btn {
88 background: #c33;
89 }
90
91 .logout-btn:hover {
92 background: #a22;
93 }
94
95 section {
96 margin: 2rem 0;
97 padding: 1.5rem;
98 border: 1px solid #ddd;
99 border-radius: 4px;
100 }
101
102 h2 {
103 font-size: 1.25rem;
104 margin-bottom: 1rem;
105 font-weight: 600;
106 }
107
108 .info-grid {
109 display: grid;
110 gap: 0.75rem;
111 }
112
113 .info-row {
114 display: grid;
115 grid-template-columns: 150px 1fr;
116 padding: 0.5rem 0;
117 border-bottom: 1px solid #eee;
118 }
119
120 .info-row:last-child {
121 border-bottom: none;
122 }
123
124 .label {
125 font-weight: 500;
126 color: #555;
127 }
128
129 .value {
130 color: #111;
131 font-family: monospace;
132 font-size: 0.95rem;
133 }
134
135 .api-key-section {
136 background: #f9f9f9;
137 padding: 1rem;
138 border-radius: 4px;
139 }
140
141 .api-key-display {
142 display: flex;
143 gap: 0.5rem;
144 margin: 1rem 0;
145 }
146
147 .api-key-value {
148 flex: 1;
149 padding: 0.75rem;
150 background: white;
151 border: 1px solid #ddd;
152 border-radius: 4px;
153 font-family: monospace;
154 font-size: 0.9rem;
155 overflow-x: auto;
156 white-space: nowrap;
157 }
158
159 .api-key-value:hover {
160 background: #f8f9fa;
161 }
162
163 .api-key-value.hidden {
164 filter: blur(6px);
165 user-select: none;
166 }
167
168 #mcpUrlValue {
169 user-select: all;
170 }
171
172 .btn-group {
173 display: flex;
174 gap: 0.5rem;
175 flex-wrap: wrap;
176 }
177
178 .config-block {
179 background: #2d2d2d;
180 color: #f0f0f0;
181 padding: 1rem;
182 border-radius: 4px;
183 font-family: monospace;
184 font-size: 0.85rem;
185 overflow-x: auto;
186 margin-top: 1rem;
187 white-space: pre;
188 }
189
190 .hidden {
191 display: none;
192 }
193
194 .notification {
195 position: fixed;
196 bottom: 2rem;
197 right: 2rem;
198 background: #2d2d2d;
199 color: white;
200 padding: 1rem 1.5rem;
201 border-radius: 4px;
202 opacity: 0;
203 transition: opacity 0.3s;
204 pointer-events: none;
205 }
206
207 .notification.show {
208 opacity: 1;
209 }
210
211 .stat-grid {
212 display: grid;
213 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
214 gap: 1rem;
215 margin-top: 1rem;
216 }
217
218 .stat {
219 text-align: center;
220 padding: 1rem;
221 background: #f9f9f9;
222 border-radius: 4px;
223 }
224
225 .stat-value {
226 font-size: 2rem;
227 font-weight: bold;
228 color: #0066cc;
229 }
230
231 .stat-label {
232 color: #666;
233 font-size: 0.9rem;
234 margin-top: 0.25rem;
235 }
236 </style>
237</head>
238
239<body>
240 <header>
241 <h1>Dashboard</h1>
242 <button class="logout-btn" id="logoutBtn">Logout</button>
243 </header>
244
245 <section id="connectCanvasSection" style="display: none;">
246 <h2>Connect Canvas Account</h2>
247 <p style="color: #666; margin-bottom: 1.5rem;">
248 Connect your Canvas account to start using the MCP server with Claude.
249 </p>
250 <form id="connectCanvasForm">
251 <div style="margin-bottom: 1rem;">
252 <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Canvas Domain</label>
253 <input type="text" id="canvasDomain" placeholder="canvas.university.edu"
254 style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem;"
255 required />
256 </div>
257 <div style="margin-bottom: 1rem;">
258 <label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Personal Access Token</label>
259 <input type="password" id="accessToken" placeholder="Get this from Canvas → Settings → New Access Token"
260 style="width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem;"
261 required />
262 </div>
263 <button type="submit" style="width: 100%; padding: 0.75rem;">Connect Canvas</button>
264 <div id="connectError"
265 style="display: none; margin-top: 1rem; padding: 0.75rem; background: #fee; border: 1px solid #fcc; border-radius: 4px; color: #c33;">
266 </div>
267 </form>
268 <details style="margin-top: 1.5rem;">
269 <summary style="cursor: pointer; color: #666;">How to get a Personal Access Token</summary>
270 <ol style="margin-top: 1rem; padding-left: 1.5rem; color: #666; font-size: 0.9rem;">
271 <li>Log in to your Canvas account</li>
272 <li>Go to Account → Settings</li>
273 <li>Scroll to "Approved Integrations"</li>
274 <li>Click "+ New Access Token"</li>
275 <li>Set Purpose: "MCP Server"</li>
276 <li>Click "Generate Token"</li>
277 <li>Copy the token and paste it above</li>
278 </ol>
279 </details>
280 </section>
281
282 <section id="accountSection" style="display: none;">
283 <h2>Account Information</h2>
284 <div class="info-grid" id="accountInfo">
285 <div class="info-row">
286 <span class="label">Loading...</span>
287 </div>
288 </div>
289 </section>
290
291 <section id="mcpConnectionSection" style="display: none;">
292 <h2>MCP Server Connection</h2>
293 <p style="color: #666; margin-bottom: 1.5rem;">
294 Use these credentials to connect your MCP client (Claude Desktop, Poke, etc.) to your Canvas account.
295 </p>
296
297 <!-- MCP Endpoint URL -->
298 <div style="margin-bottom: 1.5rem;">
299 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
300 <label style="font-weight: 600; color: #111;">MCP Server URL</label>
301 <button id="copyUrlBtn" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Copy URL</button>
302 </div>
303 <div class="api-key-value" id="mcpUrlValue">
304 <span id="mcpUrl"></span>
305 </div>
306 <p style="font-size: 0.85rem; color: #666; margin-top: 0.5rem;">
307 This is your MCP server endpoint URL.
308 </p>
309 </div>
310
311 <!-- API Token -->
312 <div class="api-key-section">
313 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
314 <label style="font-weight: 600; color: #111;">API Token</label>
315 <button id="regenerateBtn" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Regenerate Token</button>
316 </div>
317
318 <div id="apiKeyDisplay" style="display: none;">
319 <div
320 style="background: #fff3cd; border: 1px solid #ffc107; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;">
321 <strong>⚠️ Save this token now!</strong> You won't be able to see it again after leaving this page.
322 </div>
323 <div class="api-key-display">
324 <div class="api-key-value" id="apiKeyValue"></div>
325 <button id="copyKeyBtn">Copy Token</button>
326 </div>
327 </div>
328
329 <div id="apiKeyHidden" style="display: none;">
330 <div class="api-key-display">
331 <div class="api-key-value" style="background: #f5f5f5; color: #999; user-select: none;">
332 ••••••••••••••••••••••••••••••••••••••••
333 </div>
334 </div>
335 <p style="color: #666; margin-top: 0.5rem; font-size: 0.85rem;">
336 Your token is hidden for security. Click "Regenerate Token" above to create a new one.
337 </p>
338 </div>
339 </div>
340 </section>
341
342 <section id="quickSetupSection" style="display: none;">
343 <h2>Quick Setup</h2>
344 <p style="color: #666; margin-bottom: 1rem;">
345 Add the MCP url to the claude connections page as a custom connection and then authorize it. Poke and some other
346 clients require both the api key and the mcp url.
347 </p>
348 </section>
349
350 <section id="usageStatsSection" style="display: none;">
351 <h2>Usage Statistics</h2>
352 <div class="stat-grid" id="usageStats">
353 <div class="stat">
354 <div class="stat-value">-</div>
355 <div class="stat-label">Loading...</div>
356 </div>
357 </div>
358 </section>
359
360 <div class="notification" id="notification"></div>
361
362 <footer style="margin-top: 4rem; padding-top: 2rem; border-top: 1px solid #ddd; font-size: 0.9rem;">
363 <div style="display: flex; justify-content: space-between; align-items: center;">
364 <span style="color: #666;">made by <a href="https://dunkirk.sh" style="color: #666; text-decoration: none;">kieran
365 klukas</a></span>
366 <a id="git-hash-link" href="#"
367 style="color: #999; text-decoration: none; font-family: monospace; font-size: 0.85rem;">...</a>
368 </div>
369 </footer>
370
371 <script type="module">
372 // Load git hash
373 fetch('/api/version')
374 .then(r => r.json())
375 .then(data => {
376 const link = document.getElementById('git-hash-link');
377 if (link) {
378 link.href = `https://tangled.org/dunkirk.sh/canvas-mcp/commit/${data.hash}`;
379 link.textContent = data.shortHash;
380 }
381 })
382 .catch(() => { });
383
384 let userData = null;
385 let apiKeyVisible = false;
386
387 async function loadDashboard() {
388 try {
389 const response = await fetch('/api/user/me', {
390 credentials: 'include'
391 });
392
393 if (!response.ok) {
394 window.location.href = '/';
395 return;
396 }
397
398 userData = await response.json();
399
400 // Check if Canvas is connected
401 if (!userData.canvas_domain) {
402 // Show Canvas connection form, hide everything else
403 document.getElementById('connectCanvasSection').style.display = 'block';
404 document.getElementById('accountSection').style.display = 'none';
405 document.getElementById('mcpConnectionSection').style.display = 'none';
406 document.getElementById('quickSetupSection').style.display = 'none';
407 document.getElementById('usageStatsSection').style.display = 'none';
408 } else {
409 // Show everything when Canvas is connected
410 document.getElementById('connectCanvasSection').style.display = 'none';
411 document.getElementById('accountSection').style.display = 'block';
412 document.getElementById('mcpConnectionSection').style.display = 'block';
413 document.getElementById('quickSetupSection').style.display = 'block';
414 document.getElementById('usageStatsSection').style.display = 'block';
415 renderAccountInfo();
416 renderUsageStats();
417 setupApiKeyDisplay();
418 }
419 } catch (error) {
420 console.error('Failed to load dashboard:', error);
421 window.location.href = '/';
422 }
423 }
424
425 function renderAccountInfo() {
426 const container = document.getElementById('accountInfo');
427 container.innerHTML = `
428 <div class="info-row">
429 <span class="label">Canvas Domain</span>
430 <span class="value">${userData.canvas_domain}</span>
431 </div>
432 <div class="info-row">
433 <span class="label">Email</span>
434 <span class="value">${userData.email || 'Not provided'}</span>
435 </div>
436 <div class="info-row">
437 <span class="label">Created</span>
438 <span class="value">${new Date(userData.created_at).toLocaleDateString()}</span>
439 </div>
440 <div class="info-row">
441 <span class="label">Last Used</span>
442 <span class="value">${userData.last_used_at ? new Date(userData.last_used_at).toLocaleDateString() : 'Never'}</span>
443 </div>
444 `;
445 }
446
447 function renderUsageStats() {
448 const container = document.getElementById('usageStats');
449 const stats = userData.usage_stats || {};
450 container.innerHTML = `
451 <div class="stat">
452 <div class="stat-value">${stats.total_requests || 0}</div>
453 <div class="stat-label">Total Requests</div>
454 </div>
455 <div class="stat">
456 <div class="stat-value">${stats.requests_24h || 0}</div>
457 <div class="stat-label">Last 24 Hours</div>
458 </div>
459 <div class="stat">
460 <div class="stat-value">${stats.requests_7d || 0}</div>
461 <div class="stat-label">Last 7 Days</div>
462 </div>
463 `;
464 }
465
466 function showNotification(message) {
467 const notification = document.getElementById('notification');
468 notification.textContent = message;
469 notification.classList.add('show');
470 setTimeout(() => {
471 notification.classList.remove('show');
472 }, 2000);
473 }
474
475 function setupApiKeyDisplay() {
476 // Always show the MCP URL
477 const mcpUrl = `${window.location.origin}/mcp`;
478 document.getElementById('mcpUrl').textContent = mcpUrl;
479
480 if (userData.api_key) {
481 // Show the key (first time only)
482 document.getElementById('apiKeyDisplay').style.display = 'block';
483 document.getElementById('apiKeyValue').textContent = userData.api_key;
484 apiKeyVisible = true;
485 } else {
486 // Hide the key
487 document.getElementById('apiKeyHidden').style.display = 'block';
488 }
489 }
490
491 document.getElementById('copyUrlBtn').addEventListener('click', async () => {
492 const mcpUrl = `${window.location.origin}/mcp`;
493 await navigator.clipboard.writeText(mcpUrl);
494 showNotification('MCP URL copied to clipboard');
495 });
496
497 document.getElementById('copyKeyBtn').addEventListener('click', async () => {
498 await navigator.clipboard.writeText(userData.api_key);
499 showNotification('API token copied to clipboard');
500 });
501
502 let regenerateState = 'initial'; // 'initial', 'confirm', 'show-token'
503 let newToken = null;
504
505 document.getElementById('regenerateBtn').addEventListener('click', async () => {
506 const btn = document.getElementById('regenerateBtn');
507
508 if (regenerateState === 'initial') {
509 // First click: show confirmation
510 regenerateState = 'confirm';
511 btn.textContent = 'Click again to confirm';
512 btn.style.background = '#c33';
513
514 // Reset after 3 seconds if not confirmed
515 setTimeout(() => {
516 if (regenerateState === 'confirm') {
517 regenerateState = 'initial';
518 btn.textContent = 'Regenerate Token';
519 btn.style.background = '';
520 }
521 }, 3000);
522 } else if (regenerateState === 'confirm') {
523 // Second click: regenerate
524 btn.disabled = true;
525 btn.textContent = 'Regenerating...';
526
527 try {
528 const response = await fetch('/api/user/regenerate-key', {
529 method: 'POST',
530 credentials: 'include'
531 });
532
533 const data = await response.json();
534 userData.api_key = data.api_key;
535 newToken = data.api_key;
536
537 // Show the new key
538 document.getElementById('apiKeyHidden').style.display = 'none';
539 document.getElementById('apiKeyDisplay').style.display = 'block';
540 document.getElementById('apiKeyValue').textContent = userData.api_key;
541 apiKeyVisible = true;
542
543 regenerateState = 'show-token';
544 btn.disabled = false;
545 btn.textContent = 'Click to copy new token';
546 btn.style.background = '#0066cc';
547 } catch (error) {
548 regenerateState = 'initial';
549 btn.disabled = false;
550 btn.textContent = 'Regenerate Token';
551 btn.style.background = '';
552 showNotification('Failed to regenerate token');
553 }
554 } else if (regenerateState === 'show-token') {
555 // Third click: copy token
556 await navigator.clipboard.writeText(newToken);
557 showNotification('New token copied to clipboard');
558
559 // Reset button
560 regenerateState = 'initial';
561 btn.textContent = 'Regenerate Token';
562 btn.style.background = '';
563 }
564 });
565
566 document.getElementById('logoutBtn').addEventListener('click', async () => {
567 await fetch('/api/auth/logout', {
568 method: 'POST',
569 credentials: 'include'
570 });
571 window.location.href = '/';
572 });
573
574 document.getElementById('connectCanvasForm')?.addEventListener('submit', async (e) => {
575 e.preventDefault();
576
577 const domain = document.getElementById('canvasDomain').value.trim();
578 const token = document.getElementById('accessToken').value.trim();
579 const errorDiv = document.getElementById('connectError');
580 const submitBtn = e.target.querySelector('button[type="submit"]');
581
582 errorDiv.style.display = 'none';
583 submitBtn.disabled = true;
584 submitBtn.textContent = 'Connecting...';
585
586 try {
587 const response = await fetch('/api/auth/token-login', {
588 method: 'POST',
589 headers: {'Content-Type': 'application/json'},
590 credentials: 'include',
591 body: JSON.stringify({
592 canvas_domain: domain,
593 access_token: token
594 })
595 });
596
597 const data = await response.json();
598
599 if (!response.ok) {
600 throw new Error(data.error || 'Failed to connect Canvas');
601 }
602
603 // Reload dashboard to show connected state
604 window.location.reload();
605 } catch (error) {
606 errorDiv.textContent = error.message;
607 errorDiv.style.display = 'block';
608 submitBtn.disabled = false;
609 submitBtn.textContent = 'Connect Canvas';
610 }
611 });
612
613 loadDashboard();
614 </script>
615</body>
616
617</html>