a fancy canvas mcp server!
0
fork

Configure Feed

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

feat: add inital version

+2895 -3
+10
.env.example
··· 1 + # Server Configuration 2 + PORT=3000 3 + HOST=localhost 4 + BASE_URL=http://localhost:3000 5 + 6 + # Encryption (generate with: bun run generate-key) 7 + ENCRYPTION_KEY=your_encryption_key_here 8 + 9 + # Database 10 + DATABASE_PATH=./canvas-mcp.db
+7
.gitignore
··· 1 + node_modules/ 2 + .env 3 + *.db 4 + *.db-shm 5 + *.db-wal 6 + dist/ 7 + .DS_Store
+123
AUTHENTICATION.md
··· 1 + # Canvas MCP Authentication 2 + 3 + The Canvas MCP Server supports two authentication methods: 4 + 5 + ## 1. Personal Access Tokens (Recommended for Students) 6 + 7 + **Best for:** Individual students, anyone without Canvas admin access 8 + 9 + ### How it works: 10 + 1. Users generate their own Personal Access Token from Canvas 11 + 2. Paste it into the web app 12 + 3. Get an MCP API key instantly 13 + 4. No admin access required 14 + 15 + ### Setup Instructions: 16 + 17 + **For Users:** 18 + 1. Log in to your Canvas account 19 + 2. Go to **Account → Settings** 20 + 3. Scroll to **"Approved Integrations"** 21 + 4. Click **"+ New Access Token"** 22 + 5. Fill in: 23 + - **Purpose:** "MCP Server" (or anything you want) 24 + - **Expires:** (optional - max 120 days, leave blank for 120 days) 25 + 6. Click **"Generate Token"** 26 + 7. **Copy the token** (you won't see it again!) 27 + 8. Paste it into the Canvas MCP login page 28 + 29 + **Security Notes:** 30 + - Personal Access Tokens have the same permissions as your Canvas account 31 + - The token is encrypted before being stored in the database 32 + - You can revoke the token anytime from Canvas Settings 33 + - Canvas limits token expiration to a maximum of 120 days 34 + - When your token expires, just generate a new one and update it on the dashboard 35 + 36 + --- 37 + 38 + ## 2. OAuth 2.1 (For Institution-Wide Deployment) 39 + 40 + **Best for:** Canvas administrators deploying for entire institution 41 + 42 + ### How it works: 43 + 1. Admin registers OAuth application in Canvas 44 + 2. Users click "Sign in with Canvas" 45 + 3. Canvas OAuth flow handles authentication 46 + 4. Server stores encrypted tokens 47 + 48 + ### Setup Instructions: 49 + 50 + **For Canvas Administrators:** 51 + 52 + See [SETUP.md](./SETUP.md) for detailed OAuth configuration instructions. 53 + 54 + **Quick summary:** 55 + 1. Go to Canvas Admin → Developer Keys → "+ API Key" 56 + 2. Set redirect URI: `https://canvas.dunkirk.sh/api/auth/callback` 57 + 3. Request scopes: courses, assignments, users (read) 58 + 4. Copy Client ID and Client Secret 59 + 5. Add to server `.env` file 60 + 61 + --- 62 + 63 + ## Comparison 64 + 65 + | Feature | Personal Access Token | OAuth | 66 + |---------|----------------------|-------| 67 + | **Setup Complexity** | Simple (2 minutes) | Complex (requires admin) | 68 + | **Who Can Use** | Any Canvas user | Requires admin setup | 69 + | **Token Management** | User manages their own | Server manages via refresh tokens | 70 + | **Expiration** | Max 120 days (user sets) | Typically 1 hour (auto-refreshed) | 71 + | **Revocation** | User revokes in Canvas | User revokes in Canvas | 72 + | **Best For** | Individual students | Institution-wide deployment | 73 + 74 + --- 75 + 76 + ## Hybrid Deployment 77 + 78 + The server supports **both methods simultaneously**: 79 + 80 + - Students can use Personal Access Tokens (no admin needed) 81 + - Institutions can set up OAuth for easier onboarding 82 + - Users choose their preferred method on login 83 + 84 + This provides maximum flexibility. 85 + 86 + --- 87 + 88 + ## Security 89 + 90 + Both methods are secure: 91 + 92 + **Personal Access Tokens:** 93 + - Encrypted at rest using AES-256-GCM 94 + - Never logged or exposed in API responses 95 + - Only decrypted when making Canvas API calls 96 + 97 + **OAuth Tokens:** 98 + - Encrypted at rest using AES-256-GCM 99 + - Automatically refreshed before expiration 100 + - Follow OAuth 2.1 best practices with PKCE 101 + 102 + **MCP API Keys:** 103 + - Hashed using Argon2id before storage 104 + - Cannot be recovered (only verified) 105 + - Can be regenerated anytime by user 106 + 107 + --- 108 + 109 + ## Which Method Should I Use? 110 + 111 + **Use Personal Access Tokens if:** 112 + - You're a student or individual user 113 + - You don't have Canvas admin access 114 + - You want to get started in 2 minutes 115 + - You're okay managing your own token 116 + 117 + **Use OAuth if:** 118 + - You're deploying for an entire institution 119 + - You have Canvas admin access 120 + - You want users to have a simpler login flow (just click a button) 121 + - You want tokens to auto-refresh 122 + 123 + **Recommendation:** Start with Personal Access Tokens. They're simpler and work for everyone.
+194
MULTI_INSTITUTION.md
··· 1 + # Multi-Institution Support - How It Works 2 + 3 + ## Your Question: "Can we make the Canvas thing work at any institution?" 4 + 5 + **Answer: Yes!** ✅ 6 + 7 + The server is designed to work with **any Canvas institution**. Here's how: 8 + 9 + --- 10 + 11 + ## How Multi-Institution Support Works 12 + 13 + ### 1. **User-Provided Domain** 14 + 15 + When users visit the login page, they enter their Canvas domain (e.g., `canvas.harvard.edu`, `canvas.mit.edu`, `instructure.university.edu`). 16 + 17 + The server: 18 + - Accepts **any valid Canvas domain** 19 + - Uses that domain for the OAuth flow 20 + - Stores the user's specific Canvas instance in the database 21 + - Makes API calls to **their** Canvas instance (not a shared one) 22 + 23 + ### 2. **OAuth Configuration Options** 24 + 25 + You have three ways to configure OAuth: 26 + 27 + #### Option A: **Global/Wildcard** (Simplest) 28 + ```bash 29 + CANVAS_CLIENT_ID=xxx 30 + CANVAS_CLIENT_SECRET=yyy 31 + ``` 32 + 33 + This uses **one set of OAuth credentials for all institutions**. 34 + 35 + ✅ **Works if**: 36 + - You have a Canvas Cloud account with inherited developer keys 37 + - Your OAuth app is registered as a "global" developer key 38 + - All institutions in your consortium share OAuth apps 39 + 40 + #### Option B: **Per-Institution** (Most Flexible) 41 + ```bash 42 + CANVAS_INSTITUTIONS='[ 43 + {"domain":"canvas.harvard.edu","clientId":"aaa","clientSecret":"bbb"}, 44 + {"domain":"canvas.mit.edu","clientId":"ccc","clientSecret":"ddd"} 45 + ]' 46 + ``` 47 + 48 + This allows **different OAuth credentials per institution**. 49 + 50 + ✅ **Use when**: 51 + - Each institution requires separate OAuth app registration 52 + - You want fine-grained control per school 53 + - Supporting multiple independent Canvas instances 54 + 55 + #### Option C: **Hybrid** (Recommended) 56 + ```bash 57 + # Global fallback 58 + CANVAS_CLIENT_ID=global_id 59 + CANVAS_CLIENT_SECRET=global_secret 60 + 61 + # Specific overrides 62 + CANVAS_INSTITUTIONS='[ 63 + {"domain":"canvas.special-school.edu","clientId":"xxx","clientSecret":"yyy"} 64 + ]' 65 + ``` 66 + 67 + This supports **most institutions with fallback + specific overrides**. 68 + 69 + --- 70 + 71 + ## The OAuth Challenge 72 + 73 + The **only requirement** is that each Canvas institution must have your OAuth application registered. 74 + 75 + ### Who Registers the OAuth App? 76 + 77 + **Option 1: Canvas Administrators** 78 + - Each institution's Canvas admin registers your app 79 + - Provides you with Client ID and Client Secret 80 + - You add these to your server config 81 + 82 + **Option 2: Canvas Cloud Inherited Keys** 83 + - Some Canvas Cloud consortiums support "inherited" developer keys 84 + - One registration works across multiple institutions 85 + - Ask Canvas support if this is available 86 + 87 + **Option 3: User Self-Registration** (Not common) 88 + - Some Canvas instances allow users to create their own OAuth apps 89 + - Users would need to configure their own MCP server instance 90 + - Not practical for a shared service 91 + 92 + --- 93 + 94 + ## Practical Deployment Strategy 95 + 96 + ### For a Public Service (like canvas.dunkirk.sh): 97 + 98 + **Phase 1: Start with Major Institutions** 99 + ```bash 100 + CANVAS_INSTITUTIONS='[ 101 + {"domain":"canvas.harvard.edu","clientId":"...","clientSecret":"..."}, 102 + {"domain":"canvas.mit.edu","clientId":"...","clientSecret":"..."}, 103 + {"domain":"canvas.stanford.edu","clientId":"...","clientSecret":"..."} 104 + ]' 105 + ``` 106 + 107 + **Phase 2: Add Institutions on Request** 108 + - Users request support for their institution 109 + - Contact their Canvas admin to register OAuth app 110 + - Add credentials to `CANVAS_INSTITUTIONS` 111 + 112 + **Phase 3: Global Fallback** (if possible) 113 + - Get a Canvas Cloud global developer key 114 + - Set as `CANVAS_CLIENT_ID` + `CANVAS_CLIENT_SECRET` 115 + - All institutions automatically supported 116 + 117 + --- 118 + 119 + ## User Experience 120 + 121 + 1. **User visits** `https://canvas.dunkirk.sh` 122 + 2. **Enters domain**: `canvas.myschool.edu` 123 + 3. **Server checks**: Is this domain configured? 124 + - ✅ Yes → Redirect to Canvas OAuth 125 + - ❌ No → Show error: "Contact admin to add your institution" 126 + 4. **After OAuth**: User gets an API key specific to their institution 127 + 5. **MCP calls**: Go to **their specific Canvas instance** (not shared) 128 + 129 + --- 130 + 131 + ## Technical Flow 132 + 133 + ``` 134 + User (canvas.harvard.edu) 135 + 136 + Server checks OAuth config for "canvas.harvard.edu" 137 + 138 + Redirects to https://canvas.harvard.edu/login/oauth2/auth 139 + 140 + Harvard Canvas authenticates user 141 + 142 + Redirects back with code 143 + 144 + Server exchanges code for token (at Harvard's Canvas) 145 + 146 + Stores Harvard token (encrypted) + generates API key 147 + 148 + MCP client uses API key 149 + 150 + Server proxies requests to canvas.harvard.edu with user's token 151 + ``` 152 + 153 + **Key point**: Each user's API calls go to **their own institution's Canvas instance**, using **their own OAuth token**. 154 + 155 + --- 156 + 157 + ## What You Need to Support "Any Institution" 158 + 159 + ### Technically: 160 + ✅ **Already built!** The server accepts any domain and handles per-institution OAuth. 161 + 162 + ### Practically: 163 + You need OAuth credentials for each institution. Options: 164 + 165 + 1. **Contact Canvas Cloud** → Ask about global developer keys 166 + 2. **Start with your institution** → Get it working for one school first 167 + 3. **Add institutions incrementally** → As users request support 168 + 4. **Open registration** → Allow users to provide their own OAuth credentials (advanced) 169 + 170 + --- 171 + 172 + ## Recommendation 173 + 174 + **Start with Option A** (Global/Wildcard) if possible: 175 + - Contact Canvas support about Cloud global keys 176 + - Mention you're building a cross-institution MCP service 177 + - Ask if inherited developer keys are available 178 + 179 + **If not available**, use **Option B** (Per-Institution): 180 + - Start with your own Canvas instance 181 + - Add others as requested 182 + - Build a self-service OAuth registration flow (future feature) 183 + 184 + --- 185 + 186 + ## Summary 187 + 188 + ✅ **Yes, the server works with any Canvas institution** 189 + ✅ **Users can enter any Canvas domain** 190 + ✅ **Server handles institution-specific OAuth** 191 + ✅ **Each user's API calls go to their own Canvas instance** 192 + 193 + ❓ **Only requirement**: OAuth app must be registered with each institution 194 + 💡 **Solution**: Start with global config, add institutions as needed, or contact Canvas about Cloud global keys
+82 -3
README.md
··· 1 - # canvas-mcp 1 + # Canvas MCP Server 2 + 3 + A proper MCP (Model Context Protocol) server that connects Canvas LMS to AI assistants like Claude Desktop. 4 + 5 + ## Features 6 + 7 + - **Proper MCP Protocol**: Implements Streamable HTTP transport with JSON-RPC 8 + - **Personal Access Token Auth**: Students set up in 2 minutes (no admin access needed) 9 + - **Multi-Institution Support**: Works with any Canvas instance 10 + - **Encrypted Storage**: Canvas tokens encrypted at rest with AES-256-GCM 11 + - **Session Persistence**: Sessions survive server restarts (stored in SQLite) 12 + - **Built with Bun**: Fast, modern TypeScript using `@modelcontextprotocol/sdk` 13 + 14 + ## Quick Start 15 + 16 + ```bash 17 + # Install dependencies 18 + bun install 19 + 20 + # Generate encryption key 21 + bun run generate-key 22 + 23 + # Copy and configure environment 24 + cp .env.example .env 25 + # Add the generated encryption key to .env 26 + 27 + # Run development server 28 + bun dev 29 + ``` 30 + 31 + Visit `http://localhost:3000` to connect your Canvas account. 32 + 33 + ## How It Works 34 + 35 + 1. **Web Interface**: Students enter Canvas domain + Personal Access Token 36 + 2. **Verification**: Server validates token by calling Canvas API 37 + 3. **Token Storage**: Canvas token encrypted and stored server-side 38 + 4. **MCP Token**: User receives an MCP connection token for their AI client 39 + 5. **MCP Protocol**: AI client connects to `/mcp` endpoint with Bearer token 40 + 6. **Canvas Proxy**: Server proxies tool calls to Canvas using stored token 41 + 42 + ## MCP Tools 43 + 44 + - `list_courses`: List Canvas courses with enrollment filtering 45 + - `search_assignments`: Search assignments across courses 46 + - `get_assignment`: Get detailed assignment information 47 + 48 + ## Client Configuration 2 49 3 - a canvas mcp server 50 + After connecting your Canvas account, add this to Claude Desktop config: 4 51 5 - The canonical repo for this is hosted on tangled over at [`knot.dunkirk.sh/canvas-mcp`](https://tangled.org/knot.dunkirk.sh/canvas-mcp) 52 + ```json 53 + { 54 + "mcpServers": { 55 + "canvas": { 56 + "url": "https://canvas.dunkirk.sh/mcp", 57 + "headers": { 58 + "Authorization": "Bearer YOUR_MCP_TOKEN_HERE" 59 + } 60 + } 61 + } 62 + } 63 + ``` 64 + 65 + ## Architecture 66 + 67 + - **MCP Server**: `@modelcontextprotocol/sdk` with Streamable HTTP transport 68 + - **Web Dashboard**: Bun.serve with HTML/CSS/JS (no frameworks) 69 + - **Database**: SQLite with encrypted Canvas tokens and persistent sessions 70 + - **Transport**: JSON-RPC over HTTP POST at `/mcp` endpoint 71 + 72 + ## Security 73 + 74 + - Canvas tokens encrypted with AES-256-GCM before storage 75 + - MCP tokens hashed with Argon2id (cannot be retrieved after creation) 76 + - Sessions stored in database (survive restarts) 77 + - HTTPS enforced in production 78 + - No Canvas tokens exposed to MCP clients 79 + 80 + ## Deployment 81 + 82 + Deployed at: `https://canvas.dunkirk.sh` 83 + 84 + The canonical repo is hosted on tangled at [`knot.dunkirk.sh/canvas-mcp`](https://tangled.org/knot.dunkirk.sh/canvas-mcp) 6 85 7 86 <p align="center"> 8 87 <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/main/.github/images/line-break.svg" />
+213
SETUP.md
··· 1 + # Canvas MCP Server Setup 2 + 3 + This guide explains how to set up the Canvas MCP server to work with your Canvas LMS institution(s). 4 + 5 + ## Multi-Institution Support 6 + 7 + The server supports three configuration modes: 8 + 9 + ### 1. **Global/Wildcard Configuration** (Easiest) 10 + 11 + If your Canvas instance supports OAuth applications that work across different domains, or if you're only supporting one institution: 12 + 13 + ```bash 14 + CANVAS_CLIENT_ID=your_client_id 15 + CANVAS_CLIENT_SECRET=your_client_secret 16 + ``` 17 + 18 + This will accept logins from **any Canvas domain** using the same OAuth credentials. 19 + 20 + ### 2. **Multiple Specific Institutions** 21 + 22 + If you need different OAuth credentials for different institutions: 23 + 24 + ```bash 25 + CANVAS_INSTITUTIONS='[ 26 + { 27 + "domain": "canvas.harvard.edu", 28 + "clientId": "xxx", 29 + "clientSecret": "yyy", 30 + "name": "Harvard University" 31 + }, 32 + { 33 + "domain": "canvas.mit.edu", 34 + "clientId": "aaa", 35 + "clientSecret": "bbb", 36 + "name": "MIT" 37 + } 38 + ]' 39 + ``` 40 + 41 + --- 42 + 43 + ## How to Get Canvas OAuth Credentials 44 + 45 + Each Canvas institution must register your MCP server as an OAuth application. Here's how: 46 + 47 + ### For Canvas Administrators: 48 + 49 + 1. **Go to Canvas Admin Panel** 50 + - Navigate to: Admin → Developer Keys 51 + 52 + 2. **Create a new Developer Key** 53 + - Click "+ Developer Key" → "+ API Key" 54 + 55 + 3. **Fill in the details:** 56 + - **Key Name**: Canvas MCP Server 57 + - **Owner Email**: Your email 58 + - **Redirect URIs**: `https://canvas.dunkirk.sh/api/auth/callback` 59 + - **Scopes**: Select the following: 60 + - `url:GET|/api/v1/courses` 61 + - `url:GET|/api/v1/assignments` 62 + - `url:GET|/api/v1/users/self` 63 + 64 + 4. **Save and Enable** 65 + - Copy the **Client ID** and **Client Secret** 66 + - Set the key state to "On" 67 + 68 + 5. **Provide credentials to the server** 69 + - Add the Client ID and Client Secret to your `.env` file 70 + 71 + ### For Users (Self-Service): 72 + 73 + If you don't have admin access to your Canvas instance: 74 + 75 + 1. Contact your Canvas LMS administrator 76 + 2. Ask them to register an OAuth application with the redirect URI: `https://canvas.dunkirk.sh/api/auth/callback` 77 + 3. Request the Client ID and Client Secret 78 + 4. Provide these to the Canvas MCP server administrator 79 + 80 + --- 81 + 82 + ## Canvas Cloud & Inherited Developer Keys 83 + 84 + Some Canvas Cloud institutions support **inherited developer keys** across a consortium. If your institution is part of a Canvas Cloud consortium, a single OAuth application might work across multiple domains. 85 + 86 + Ask your Canvas administrator if this is available. 87 + 88 + --- 89 + 90 + ## Environment Variables 91 + 92 + Copy `.env.example` to `.env` and fill in your values: 93 + 94 + ```bash 95 + cp .env.example .env 96 + ``` 97 + 98 + ### Required Variables: 99 + 100 + ```bash 101 + # Server 102 + PORT=3000 103 + HOST=localhost 104 + BASE_URL=https://canvas.dunkirk.sh 105 + 106 + # Encryption key (generate with: openssl rand -base64 32) 107 + ENCRYPTION_KEY=your_encryption_key_here 108 + 109 + # Canvas OAuth (choose one of the options above) 110 + CANVAS_CLIENT_ID=your_client_id 111 + CANVAS_CLIENT_SECRET=your_client_secret 112 + 113 + # Database 114 + DATABASE_PATH=./canvas-mcp.db 115 + ``` 116 + 117 + --- 118 + 119 + ## Installation 120 + 121 + ```bash 122 + # Install dependencies 123 + bun install 124 + 125 + # Run development server 126 + bun dev 127 + 128 + # Build for production 129 + bun run build 130 + 131 + # Run production server 132 + bun start 133 + ``` 134 + 135 + --- 136 + 137 + ## Usage 138 + 139 + 1. **Users visit**: `https://canvas.dunkirk.sh` 140 + 2. **Enter their Canvas domain**: e.g., `canvas.harvard.edu` 141 + 3. **Authenticate via Canvas OAuth** 142 + 4. **Receive an MCP API key** on their dashboard 143 + 5. **Configure their MCP client** with the API key 144 + 145 + --- 146 + 147 + ## MCP Client Configuration 148 + 149 + After getting an API key, users should add this to their MCP client config: 150 + 151 + ```json 152 + { 153 + "mcpServers": { 154 + "canvas": { 155 + "command": "bunx", 156 + "args": ["canvas-mcp-client"], 157 + "env": { 158 + "CANVAS_MCP_API_KEY": "cmcp_...", 159 + "CANVAS_MCP_URL": "https://canvas.dunkirk.sh" 160 + } 161 + } 162 + } 163 + } 164 + ``` 165 + 166 + --- 167 + 168 + ## Security Notes 169 + 170 + - **API keys are hashed** before storage using Argon2id 171 + - **Canvas tokens are encrypted** at rest using AES-256-GCM 172 + - **OAuth state parameters** prevent CSRF attacks 173 + - **HTTPS required** in production 174 + - **Session cookies** are HttpOnly and SameSite=Lax 175 + 176 + --- 177 + 178 + ## Deployment 179 + 180 + Deploy to any platform that supports Bun: 181 + 182 + - **Railway**: `railway up` 183 + - **Fly.io**: `fly launch` 184 + - **Docker**: See Dockerfile 185 + - **VPS**: Run with systemd or PM2 186 + 187 + Make sure to: 188 + - Set `BASE_URL` to your production domain 189 + - Use HTTPS (required for OAuth) 190 + - Set a strong `ENCRYPTION_KEY` 191 + - Configure Canvas OAuth redirect URI to your production URL 192 + 193 + --- 194 + 195 + ## Troubleshooting 196 + 197 + ### "Canvas domain is not configured" 198 + 199 + The server doesn't have OAuth credentials for that Canvas instance. Either: 200 + - Use a global wildcard configuration (`CANVAS_CLIENT_ID` + `CANVAS_CLIENT_SECRET`) 201 + - Add the specific domain to `CANVAS_INSTITUTIONS` 202 + 203 + ### "OAuth token exchange failed" 204 + 205 + - Verify the Client ID and Client Secret are correct 206 + - Check that the redirect URI in Canvas matches exactly: `https://canvas.dunkirk.sh/api/auth/callback` 207 + - Ensure the Canvas domain is correct (no `https://`, just `canvas.university.edu`) 208 + 209 + ### "Invalid API key" 210 + 211 + - The API key might have been regenerated 212 + - Copy the new API key from the dashboard 213 + - Update your MCP client configuration
+156
TEST_MCP.md
··· 1 + # Testing the MCP Server 2 + 3 + ## Method 1: Direct JSON-RPC Test (Quick) 4 + 5 + Test the MCP endpoint directly with curl: 6 + 7 + ```bash 8 + # Set your MCP token (get this from the dashboard) 9 + TOKEN="cmcp_your_token_here" 10 + 11 + # Test: List available tools 12 + curl -X POST https://canvas.bore.dunkirk.sh/mcp \ 13 + -H "Authorization: Bearer $TOKEN" \ 14 + -H "Content-Type: application/json" \ 15 + -H "Accept: application/json, text/event-stream" \ 16 + -d '{ 17 + "jsonrpc": "2.0", 18 + "id": 1, 19 + "method": "tools/list", 20 + "params": {} 21 + }' 22 + 23 + # Test: List courses 24 + curl -X POST https://canvas.bore.dunkirk.sh/mcp \ 25 + -H "Authorization: Bearer $TOKEN" \ 26 + -H "Content-Type: application/json" \ 27 + -H "Accept: application/json, text/event-stream" \ 28 + -d '{ 29 + "jsonrpc": "2.0", 30 + "id": 2, 31 + "method": "tools/call", 32 + "params": { 33 + "name": "list_courses", 34 + "arguments": { 35 + "enrollment_state": "active" 36 + } 37 + } 38 + }' 39 + ``` 40 + 41 + ## Method 2: Claude Desktop (Real Usage) 42 + 43 + 1. Get your MCP token from the dashboard 44 + 2. Open Claude Desktop config: 45 + - Mac: `~/Library/Application Support/Claude/claude_desktop_config.json` 46 + - Windows: `%APPDATA%\Claude\claude_desktop_config.json` 47 + 48 + 3. Add this configuration: 49 + 50 + ```json 51 + { 52 + "mcpServers": { 53 + "canvas": { 54 + "url": "https://canvas.bore.dunkirk.sh/mcp", 55 + "headers": { 56 + "Authorization": "Bearer YOUR_MCP_TOKEN_HERE" 57 + } 58 + } 59 + } 60 + } 61 + ``` 62 + 63 + 4. Restart Claude Desktop 64 + 65 + 5. Test by asking Claude: 66 + - "What courses am I enrolled in?" 67 + - "What assignments do I have due this week?" 68 + - "Show me details about assignment ID 12345 in course 6789" 69 + 70 + ## Method 3: MCP Inspector (Visual Debugging) 71 + 72 + ```bash 73 + # Install MCP Inspector 74 + npm install -g @modelcontextprotocol/inspector 75 + 76 + # Create a config file 77 + cat > mcp-config.json <<EOF 78 + { 79 + "mcpServers": { 80 + "canvas": { 81 + "url": "https://canvas.bore.dunkirk.sh/mcp", 82 + "headers": { 83 + "Authorization": "Bearer YOUR_MCP_TOKEN_HERE" 84 + } 85 + } 86 + } 87 + } 88 + EOF 89 + 90 + # Run inspector 91 + mcp-inspector mcp-config.json 92 + ``` 93 + 94 + ## Expected Responses 95 + 96 + ### tools/list 97 + ```json 98 + { 99 + "jsonrpc": "2.0", 100 + "id": 1, 101 + "result": { 102 + "tools": [ 103 + { 104 + "name": "list_courses", 105 + "description": "List Canvas courses...", 106 + "inputSchema": {...} 107 + }, 108 + { 109 + "name": "search_assignments", 110 + ... 111 + }, 112 + { 113 + "name": "get_assignment", 114 + ... 115 + } 116 + ] 117 + } 118 + } 119 + ``` 120 + 121 + ### tools/call (list_courses) 122 + ```json 123 + { 124 + "jsonrpc": "2.0", 125 + "id": 2, 126 + "result": { 127 + "content": [ 128 + { 129 + "type": "text", 130 + "text": "[{\"id\": 123, \"name\": \"Biology 101\", ...}]" 131 + } 132 + ] 133 + } 134 + } 135 + ``` 136 + 137 + ## Troubleshooting 138 + 139 + **Error: "Missing session token"** 140 + - Make sure you're including the `Authorization: Bearer YOUR_TOKEN` header 141 + 142 + **Error: "Invalid or expired session token"** 143 + - Your MCP token expired or was regenerated 144 + - Get a new token from the dashboard 145 + 146 + **Error: "Not authenticated"** 147 + - The MCP token doesn't match any user in the database 148 + - Log in again via the web interface 149 + 150 + **Error: "Unknown tool"** 151 + - Check the tool name spelling (case-sensitive) 152 + - Run `tools/list` to see available tools 153 + 154 + **Error: Canvas API errors** 155 + - Your Canvas Personal Access Token may have expired (max 120 days) 156 + - Generate a new Canvas token and update via the web interface
+209
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "canvas-mcp", 7 + "dependencies": { 8 + "@modelcontextprotocol/sdk": "^1.0.4", 9 + "zod": "^3.23.8", 10 + }, 11 + "devDependencies": { 12 + "@types/bun": "latest", 13 + }, 14 + }, 15 + }, 16 + "packages": { 17 + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], 18 + 19 + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], 20 + 21 + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], 22 + 23 + "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], 24 + 25 + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], 26 + 27 + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], 28 + 29 + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], 30 + 31 + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], 32 + 33 + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], 34 + 35 + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 36 + 37 + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], 38 + 39 + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], 40 + 41 + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], 42 + 43 + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], 44 + 45 + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], 46 + 47 + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], 48 + 49 + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], 50 + 51 + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 52 + 53 + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 54 + 55 + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], 56 + 57 + "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 + 59 + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], 60 + 61 + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], 62 + 63 + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 64 + 65 + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 66 + 67 + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 68 + 69 + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 70 + 71 + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 72 + 73 + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], 74 + 75 + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], 76 + 77 + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], 78 + 79 + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], 80 + 81 + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 82 + 83 + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], 84 + 85 + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], 86 + 87 + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], 88 + 89 + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], 90 + 91 + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 92 + 93 + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], 94 + 95 + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], 96 + 97 + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 98 + 99 + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], 100 + 101 + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 102 + 103 + "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], 104 + 105 + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], 106 + 107 + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], 108 + 109 + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 110 + 111 + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], 112 + 113 + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 114 + 115 + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], 116 + 117 + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 118 + 119 + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], 120 + 121 + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], 122 + 123 + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], 124 + 125 + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 126 + 127 + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], 128 + 129 + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], 130 + 131 + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 132 + 133 + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], 134 + 135 + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 136 + 137 + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], 138 + 139 + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], 140 + 141 + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], 142 + 143 + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], 144 + 145 + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], 146 + 147 + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], 148 + 149 + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 150 + 151 + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], 152 + 153 + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], 154 + 155 + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], 156 + 157 + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], 158 + 159 + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], 160 + 161 + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], 162 + 163 + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], 164 + 165 + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], 166 + 167 + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 168 + 169 + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], 170 + 171 + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], 172 + 173 + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], 174 + 175 + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 176 + 177 + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 178 + 179 + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], 180 + 181 + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], 182 + 183 + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], 184 + 185 + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], 186 + 187 + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], 188 + 189 + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], 190 + 191 + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], 192 + 193 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 194 + 195 + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], 196 + 197 + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], 198 + 199 + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 200 + 201 + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], 202 + 203 + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 204 + 205 + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], 206 + 207 + "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], 208 + } 209 + }
+18
package.json
··· 1 + { 2 + "name": "canvas-mcp", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "scripts": { 6 + "dev": "bun run --hot src/index.ts", 7 + "build": "bun build --target=bun --production --outdir=dist ./src/index.ts", 8 + "start": "bun run dist/index.js", 9 + "generate-key": "bun run scripts/generate-key.ts" 10 + }, 11 + "dependencies": { 12 + "@modelcontextprotocol/sdk": "^1.0.4", 13 + "zod": "^3.23.8" 14 + }, 15 + "devDependencies": { 16 + "@types/bun": "latest" 17 + } 18 + }
+10
scripts/generate-key.ts
··· 1 + #!/usr/bin/env bun 2 + // Generate a secure encryption key for the .env file 3 + 4 + import { randomBytes } from "crypto"; 5 + 6 + const key = randomBytes(32).toString("base64"); 7 + 8 + console.log("\n🔐 Generated Encryption Key:"); 9 + console.log("\nENCRYPTION_KEY=" + key); 10 + console.log("\nAdd this to your .env file\n");
+226
src/index.ts
··· 1 + import { randomBytes } from "crypto"; 2 + import DB from "./lib/db.js"; 3 + import { CanvasClient } from "./lib/canvas.js"; 4 + import { 5 + handleMcpRequest, 6 + getProtectedResourceMetadata, 7 + } from "./lib/mcp-transport.js"; 8 + 9 + // Import HTML pages 10 + import indexPage from "./public/index.html"; 11 + import dashboardPage from "./public/dashboard.html"; 12 + 13 + // Configuration 14 + const PORT = parseInt(process.env.PORT || "3000"); 15 + const HOST = process.env.HOST || "localhost"; 16 + const BASE_URL = process.env.BASE_URL || `http://${HOST}:${PORT}`; 17 + 18 + // Generate session cookie 19 + function generateSessionId(): string { 20 + return randomBytes(32).toString("base64url"); 21 + } 22 + 23 + // Get session from cookie 24 + function getSession(req: Request) { 25 + const cookie = req.headers.get("cookie"); 26 + if (!cookie) return null; 27 + 28 + const sessionCookie = cookie 29 + .split(";") 30 + .find((c) => c.trim().startsWith("session=")); 31 + if (!sessionCookie) return null; 32 + 33 + const sessionId = sessionCookie.split("=")[1]; 34 + return DB.getSession(sessionId); 35 + } 36 + 37 + // Routes 38 + const routes = { 39 + // Web pages 40 + "/": indexPage, 41 + "/dashboard": dashboardPage, 42 + 43 + // MCP Protocol endpoint (Streamable HTTP) 44 + "/mcp": { 45 + async POST(req: Request) { 46 + // Extract Bearer token from Authorization header 47 + const authHeader = req.headers.get("Authorization"); 48 + const token = authHeader?.startsWith("Bearer ") 49 + ? authHeader.slice(7) 50 + : undefined; 51 + 52 + return handleMcpRequest(req, token); 53 + }, 54 + }, 55 + 56 + // Protected Resource Metadata (OAuth discovery) 57 + "/.well-known/oauth-protected-resource": { 58 + GET() { 59 + return Response.json(getProtectedResourceMetadata(BASE_URL)); 60 + }, 61 + }, 62 + 63 + // Auth endpoints 64 + "/api/auth/token-login": { 65 + async POST(req: Request) { 66 + try { 67 + const { canvas_domain, access_token } = await req.json(); 68 + 69 + if (!canvas_domain || !access_token) { 70 + return Response.json( 71 + { error: "Canvas domain and access token are required" }, 72 + { status: 400 }, 73 + ); 74 + } 75 + 76 + // Verify the token by making a test API call 77 + const client = new CanvasClient(canvas_domain, access_token); 78 + 79 + let canvasUser; 80 + try { 81 + canvasUser = await client.getCurrentUser(); 82 + } catch (error: any) { 83 + return Response.json( 84 + { 85 + error: 86 + "Invalid access token or Canvas domain. Please check your credentials and try again.", 87 + }, 88 + { status: 401 }, 89 + ); 90 + } 91 + 92 + // Create or update user 93 + const { user, apiKey, isNewUser } = await DB.createOrUpdateUser({ 94 + canvas_user_id: canvasUser.id.toString(), 95 + canvas_domain, 96 + email: canvasUser.primary_email || canvasUser.login_id, 97 + canvas_access_token: access_token, 98 + }); 99 + 100 + // Create session with MCP token (only for new users) 101 + const sessionId = generateSessionId(); 102 + DB.createSession(sessionId, { 103 + canvas_domain, 104 + state: "", 105 + user_id: user.id, 106 + api_key: isNewUser ? apiKey : undefined, 107 + maxAge: 2592000, // 30 days 108 + }); 109 + 110 + return Response.json( 111 + { success: true }, 112 + { 113 + headers: { 114 + "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax${ 115 + BASE_URL.startsWith("https") ? "; Secure" : "" 116 + }`, 117 + }, 118 + }, 119 + ); 120 + } catch (error: any) { 121 + console.error("Token login error:", error); 122 + return Response.json( 123 + { error: error.message || "Login failed" }, 124 + { status: 500 }, 125 + ); 126 + } 127 + }, 128 + }, 129 + 130 + "/api/auth/logout": { 131 + async POST(req: Request) { 132 + const session = getSession(req); 133 + if (session) { 134 + DB.deleteSession(session.id); 135 + } 136 + 137 + return Response.json( 138 + { success: true }, 139 + { 140 + headers: { 141 + "Set-Cookie": "session=; HttpOnly; Path=/; Max-Age=0", 142 + }, 143 + }, 144 + ); 145 + }, 146 + }, 147 + 148 + // User endpoints 149 + "/api/user/me": { 150 + async GET(req: Request) { 151 + const session = getSession(req); 152 + if (!session?.user_id) { 153 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 154 + } 155 + 156 + const userData = DB.raw 157 + .query("SELECT * FROM users WHERE id = ?") 158 + .get(session.user_id) as any; 159 + 160 + if (!userData) { 161 + return Response.json({ error: "User not found" }, { status: 404 }); 162 + } 163 + 164 + // Get usage stats 165 + const allUsage = DB.getUsageStats(userData.id); 166 + const last24h = DB.getUsageStats( 167 + userData.id, 168 + Date.now() - 24 * 60 * 60 * 1000, 169 + ); 170 + const last7d = DB.getUsageStats( 171 + userData.id, 172 + Date.now() - 7 * 24 * 60 * 60 * 1000, 173 + ); 174 + 175 + // Get MCP token from session (if just created) or hide it 176 + const apiKey = session.api_key || null; 177 + 178 + // Clear token from session after first view 179 + if (session.api_key) { 180 + DB.clearApiKeyFromSession(session.id); 181 + } 182 + 183 + return Response.json({ 184 + canvas_domain: userData.canvas_domain, 185 + email: userData.email, 186 + created_at: userData.created_at, 187 + last_used_at: userData.last_used_at, 188 + api_key: apiKey, 189 + usage_stats: { 190 + total_requests: allUsage.length, 191 + requests_24h: last24h.length, 192 + requests_7d: last7d.length, 193 + }, 194 + }); 195 + }, 196 + }, 197 + 198 + "/api/user/regenerate-key": { 199 + async POST(req: Request) { 200 + const session = getSession(req); 201 + if (!session?.user_id) { 202 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 203 + } 204 + 205 + const newApiKey = await DB.regenerateApiKey(session.user_id); 206 + 207 + return Response.json({ api_key: newApiKey }); 208 + }, 209 + }, 210 + }; 211 + 212 + // Start server 213 + const server = Bun.serve({ 214 + port: PORT, 215 + routes, 216 + development: Bun.env.NODE_ENV !== "production", 217 + 218 + fetch(req) { 219 + console.log(`${req.method} ${new URL(req.url).pathname}`); 220 + return new Response("Not Found", { status: 404 }); 221 + }, 222 + }); 223 + 224 + console.log(`Canvas MCP Server running at ${BASE_URL}`); 225 + console.log(`Dashboard: ${BASE_URL}/dashboard`); 226 + console.log(`MCP Endpoint: ${BASE_URL}/mcp`);
+252
src/lib/canvas.ts
··· 1 + // Canvas API client 2 + export class CanvasClient { 3 + constructor( 4 + private domain: string, 5 + private accessToken: string 6 + ) {} 7 + 8 + private async request(path: string, options?: RequestInit): Promise<any> { 9 + const url = `https://${this.domain}/api/v1${path}`; 10 + 11 + const response = await fetch(url, { 12 + ...options, 13 + headers: { 14 + Authorization: `Bearer ${this.accessToken}`, 15 + "Content-Type": "application/json", 16 + ...options?.headers, 17 + }, 18 + }); 19 + 20 + if (!response.ok) { 21 + throw new Error( 22 + `Canvas API error: ${response.status} ${response.statusText}` 23 + ); 24 + } 25 + 26 + return response.json(); 27 + } 28 + 29 + async getCurrentUser() { 30 + return this.request("/users/self"); 31 + } 32 + 33 + async listCourses(params?: { enrollment_state?: string }) { 34 + const query = new URLSearchParams(params as any).toString(); 35 + const path = `/courses${query ? `?${query}` : ""}`; 36 + return this.request(path); 37 + } 38 + 39 + async searchAssignments(params?: { 40 + search_term?: string; 41 + course_ids?: number[]; 42 + }) { 43 + // Get courses to search 44 + let courses: any[]; 45 + if (params?.course_ids && params.course_ids.length > 0) { 46 + // Use specific course IDs 47 + courses = params.course_ids.map(id => ({ id })); 48 + } else { 49 + // Get all active courses 50 + courses = await this.listCourses({ enrollment_state: "active" }); 51 + } 52 + 53 + // Fetch assignments from each course 54 + const allAssignments: any[] = []; 55 + for (const course of courses) { 56 + try { 57 + const assignments = await this.request(`/courses/${course.id}/assignments`); 58 + // Add course info to each assignment 59 + assignments.forEach((assignment: any) => { 60 + assignment.course_id = course.id; 61 + assignment.course_name = course.name; 62 + }); 63 + allAssignments.push(...assignments); 64 + } catch (error) { 65 + // Skip courses that fail (e.g., no permission) 66 + console.error(`Failed to fetch assignments for course ${course.id}:`, error); 67 + } 68 + } 69 + 70 + // Filter by search term if provided 71 + if (params?.search_term) { 72 + const searchLower = params.search_term.toLowerCase(); 73 + return allAssignments.filter(assignment => 74 + assignment.name?.toLowerCase().includes(searchLower) || 75 + assignment.description?.toLowerCase().includes(searchLower) 76 + ); 77 + } 78 + 79 + return allAssignments; 80 + } 81 + 82 + async getAssignment(courseId: number, assignmentId: number) { 83 + return this.request(`/courses/${courseId}/assignments/${assignmentId}`); 84 + } 85 + 86 + async getUpcomingAssignments() { 87 + // Get upcoming assignments using the planner API 88 + const startDate = new Date().toISOString(); 89 + const endDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days from now 90 + 91 + // Get all planner items without filter to see what Canvas shows in the planner 92 + return this.request(`/planner/items?start_date=${startDate}&end_date=${endDate}`); 93 + } 94 + 95 + async getCourseAnnouncements(courseId?: number, limit: number = 10) { 96 + if (courseId) { 97 + // Get announcements for a specific course 98 + return this.request(`/courses/${courseId}/discussion_topics?only_announcements=true&per_page=${limit}`); 99 + } else { 100 + // Get announcements across all courses 101 + const courses = await this.listCourses({ enrollment_state: "active" }); 102 + const allAnnouncements: any[] = []; 103 + 104 + for (const course of courses) { 105 + try { 106 + const announcements = await this.request(`/courses/${course.id}/discussion_topics?only_announcements=true&per_page=5`); 107 + announcements.forEach((announcement: any) => { 108 + announcement.course_id = course.id; 109 + announcement.course_name = course.name; 110 + }); 111 + allAnnouncements.push(...announcements); 112 + } catch (error) { 113 + // Skip courses that fail 114 + console.error(`Failed to fetch announcements for course ${course.id}:`, error); 115 + } 116 + } 117 + 118 + // Sort by posted date (most recent first) 119 + allAnnouncements.sort((a, b) => 120 + new Date(b.posted_at || b.created_at).getTime() - new Date(a.posted_at || a.created_at).getTime() 121 + ); 122 + 123 + return allAnnouncements.slice(0, limit); 124 + } 125 + } 126 + 127 + async getGradesAndSubmissions(courseId?: number) { 128 + if (courseId) { 129 + // Get submissions for a specific course 130 + const enrollments = await this.request(`/courses/${courseId}/enrollments?user_id=self&include[]=current_grading_period_scores&include[]=total_scores`); 131 + const assignments = await this.request(`/courses/${courseId}/assignments?include[]=submission`); 132 + 133 + return { 134 + enrollments, 135 + assignments 136 + }; 137 + } else { 138 + // Get grades across all courses 139 + const courses = await this.listCourses({ enrollment_state: "active" }); 140 + const allGrades: any[] = []; 141 + 142 + for (const course of courses) { 143 + try { 144 + const enrollments = await this.request(`/courses/${course.id}/enrollments?user_id=self&include[]=current_grading_period_scores&include[]=total_scores`); 145 + 146 + enrollments.forEach((enrollment: any) => { 147 + allGrades.push({ 148 + course_id: course.id, 149 + course_name: course.name, 150 + course_code: course.course_code, 151 + current_grade: enrollment.grades?.current_grade, 152 + current_score: enrollment.grades?.current_score, 153 + final_grade: enrollment.grades?.final_grade, 154 + final_score: enrollment.grades?.final_score 155 + }); 156 + }); 157 + } catch (error) { 158 + console.error(`Failed to fetch grades for course ${course.id}:`, error); 159 + } 160 + } 161 + 162 + return allGrades; 163 + } 164 + } 165 + } 166 + 167 + // OAuth helpers 168 + export interface CanvasOAuthConfig { 169 + clientId: string; 170 + clientSecret: string; 171 + redirectUri: string; 172 + canvasDomain: string; 173 + } 174 + 175 + export function getAuthorizationUrl(config: CanvasOAuthConfig, state: string): string { 176 + const params = new URLSearchParams({ 177 + client_id: config.clientId, 178 + response_type: "code", 179 + redirect_uri: config.redirectUri, 180 + state, 181 + scope: "url:GET|/api/v1/courses url:GET|/api/v1/assignments", 182 + }); 183 + 184 + return `https://${config.canvasDomain}/login/oauth2/auth?${params.toString()}`; 185 + } 186 + 187 + export async function exchangeCodeForToken( 188 + config: CanvasOAuthConfig, 189 + code: string 190 + ): Promise<{ 191 + access_token: string; 192 + refresh_token?: string; 193 + expires_in?: number; 194 + user: any; 195 + }> { 196 + const response = await fetch( 197 + `https://${config.canvasDomain}/login/oauth2/token`, 198 + { 199 + method: "POST", 200 + headers: { "Content-Type": "application/json" }, 201 + body: JSON.stringify({ 202 + grant_type: "authorization_code", 203 + client_id: config.clientId, 204 + client_secret: config.clientSecret, 205 + redirect_uri: config.redirectUri, 206 + code, 207 + }), 208 + } 209 + ); 210 + 211 + if (!response.ok) { 212 + throw new Error(`OAuth token exchange failed: ${response.statusText}`); 213 + } 214 + 215 + const data = await response.json(); 216 + 217 + // Get user info 218 + const client = new CanvasClient(config.canvasDomain, data.access_token); 219 + const user = await client.getCurrentUser(); 220 + 221 + return { 222 + access_token: data.access_token, 223 + refresh_token: data.refresh_token, 224 + expires_in: data.expires_in, 225 + user, 226 + }; 227 + } 228 + 229 + export async function refreshAccessToken( 230 + config: CanvasOAuthConfig, 231 + refreshToken: string 232 + ): Promise<{ access_token: string; expires_in?: number }> { 233 + const response = await fetch( 234 + `https://${config.canvasDomain}/login/oauth2/token`, 235 + { 236 + method: "POST", 237 + headers: { "Content-Type": "application/json" }, 238 + body: JSON.stringify({ 239 + grant_type: "refresh_token", 240 + client_id: config.clientId, 241 + client_secret: config.clientSecret, 242 + refresh_token: refreshToken, 243 + }), 244 + } 245 + ); 246 + 247 + if (!response.ok) { 248 + throw new Error(`Token refresh failed: ${response.statusText}`); 249 + } 250 + 251 + return response.json(); 252 + }
+333
src/lib/db.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; 3 + 4 + const db = new Database(process.env.DATABASE_PATH || "./canvas-mcp.db"); 5 + 6 + // Initialize database schema 7 + db.exec(` 8 + CREATE TABLE IF NOT EXISTS users ( 9 + id INTEGER PRIMARY KEY AUTOINCREMENT, 10 + canvas_user_id TEXT UNIQUE NOT NULL, 11 + canvas_domain TEXT NOT NULL, 12 + email TEXT, 13 + canvas_access_token TEXT NOT NULL, 14 + canvas_refresh_token TEXT, 15 + mcp_api_key TEXT UNIQUE NOT NULL, 16 + created_at INTEGER NOT NULL, 17 + last_used_at INTEGER, 18 + token_expires_at INTEGER 19 + ); 20 + 21 + CREATE TABLE IF NOT EXISTS usage_logs ( 22 + id INTEGER PRIMARY KEY AUTOINCREMENT, 23 + user_id INTEGER NOT NULL, 24 + endpoint TEXT NOT NULL, 25 + timestamp INTEGER NOT NULL, 26 + FOREIGN KEY (user_id) REFERENCES users(id) 27 + ); 28 + 29 + CREATE TABLE IF NOT EXISTS sessions ( 30 + id TEXT PRIMARY KEY, 31 + user_id INTEGER, 32 + canvas_domain TEXT NOT NULL, 33 + state TEXT, 34 + api_key TEXT, 35 + created_at INTEGER NOT NULL, 36 + expires_at INTEGER NOT NULL 37 + ); 38 + 39 + CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(mcp_api_key); 40 + CREATE INDEX IF NOT EXISTS idx_users_canvas_id ON users(canvas_user_id); 41 + CREATE INDEX IF NOT EXISTS idx_usage_logs_user_id ON usage_logs(user_id); 42 + CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); 43 + `); 44 + 45 + // Encryption utilities 46 + const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY || "", "base64"); 47 + const ALGORITHM = "aes-256-gcm"; 48 + 49 + function encrypt(text: string): string { 50 + const iv = randomBytes(16); 51 + const cipher = createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv); 52 + 53 + let encrypted = cipher.update(text, "utf8", "hex"); 54 + encrypted += cipher.final("hex"); 55 + 56 + const authTag = cipher.getAuthTag(); 57 + 58 + // Return: iv:authTag:encrypted 59 + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; 60 + } 61 + 62 + function decrypt(encryptedData: string): string { 63 + const [ivHex, authTagHex, encrypted] = encryptedData.split(":"); 64 + 65 + const iv = Buffer.from(ivHex, "hex"); 66 + const authTag = Buffer.from(authTagHex, "hex"); 67 + const decipher = createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv); 68 + 69 + decipher.setAuthTag(authTag); 70 + 71 + let decrypted = decipher.update(encrypted, "hex", "utf8"); 72 + decrypted += decipher.final("utf8"); 73 + 74 + return decrypted; 75 + } 76 + 77 + // Generate secure API key 78 + function generateApiKey(): string { 79 + return `cmcp_${randomBytes(32).toString("base64url")}`; 80 + } 81 + 82 + // Hash API key for storage 83 + async function hashApiKey(apiKey: string): Promise<string> { 84 + return await Bun.password.hash(apiKey, { 85 + algorithm: "argon2id", 86 + memoryCost: 19456, 87 + timeCost: 2, 88 + }); 89 + } 90 + 91 + // Verify API key 92 + async function verifyApiKey(apiKey: string, hash: string): Promise<boolean> { 93 + return await Bun.password.verify(apiKey, hash); 94 + } 95 + 96 + export interface User { 97 + id: number; 98 + canvas_user_id: string; 99 + canvas_domain: string; 100 + email?: string; 101 + canvas_access_token: string; 102 + canvas_refresh_token?: string; 103 + mcp_api_key: string; 104 + created_at: number; 105 + last_used_at?: number; 106 + token_expires_at?: number; 107 + } 108 + 109 + export const DB = { 110 + // Raw database access 111 + raw: db, 112 + 113 + // Create or update user after OAuth 114 + async createOrUpdateUser(data: { 115 + canvas_user_id: string; 116 + canvas_domain: string; 117 + email?: string; 118 + canvas_access_token: string; 119 + canvas_refresh_token?: string; 120 + token_expires_at?: number; 121 + }): Promise<{ user: User; apiKey: string | null; isNewUser: boolean }> { 122 + const encryptedToken = encrypt(data.canvas_access_token); 123 + const encryptedRefreshToken = data.canvas_refresh_token 124 + ? encrypt(data.canvas_refresh_token) 125 + : null; 126 + 127 + // Check if user exists 128 + const existing = db 129 + .query("SELECT * FROM users WHERE canvas_user_id = ?") 130 + .get(data.canvas_user_id) as User | null; 131 + 132 + if (existing) { 133 + // Update existing user 134 + db.run( 135 + `UPDATE users SET 136 + canvas_access_token = ?, 137 + canvas_refresh_token = ?, 138 + token_expires_at = ?, 139 + last_used_at = ? 140 + WHERE canvas_user_id = ?`, 141 + [ 142 + encryptedToken, 143 + encryptedRefreshToken, 144 + data.token_expires_at, 145 + Date.now(), 146 + data.canvas_user_id, 147 + ] 148 + ); 149 + 150 + const user = db 151 + .query("SELECT * FROM users WHERE canvas_user_id = ?") 152 + .get(data.canvas_user_id) as User; 153 + 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 }; 157 + } else { 158 + // Create new user with API key 159 + const apiKey = generateApiKey(); 160 + const hashedApiKey = await hashApiKey(apiKey); 161 + 162 + const result = db.run( 163 + `INSERT INTO users ( 164 + canvas_user_id, canvas_domain, email, 165 + canvas_access_token, canvas_refresh_token, 166 + mcp_api_key, created_at, token_expires_at 167 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 168 + [ 169 + data.canvas_user_id, 170 + data.canvas_domain, 171 + data.email, 172 + encryptedToken, 173 + encryptedRefreshToken, 174 + hashedApiKey, 175 + Date.now(), 176 + data.token_expires_at, 177 + ] 178 + ); 179 + 180 + const user = db 181 + .query("SELECT * FROM users WHERE id = ?") 182 + .get(result.lastInsertRowid) as User; 183 + 184 + return { user, apiKey, isNewUser: true }; 185 + } 186 + }, 187 + 188 + // Get user by API key 189 + async getUserByApiKey(apiKey: string): Promise<User | null> { 190 + const users = db.query("SELECT * FROM users").all() as User[]; 191 + 192 + for (const user of users) { 193 + if (await verifyApiKey(apiKey, user.mcp_api_key)) { 194 + return user; 195 + } 196 + } 197 + 198 + return null; 199 + }, 200 + 201 + // Get user by Canvas user ID 202 + getUserByCanvasId(canvas_user_id: string): User | null { 203 + return db 204 + .query("SELECT * FROM users WHERE canvas_user_id = ?") 205 + .get(canvas_user_id) as User | null; 206 + }, 207 + 208 + // Get decrypted Canvas token for user 209 + getCanvasToken(user: User): string { 210 + return decrypt(user.canvas_access_token); 211 + }, 212 + 213 + // Get decrypted refresh token 214 + getRefreshToken(user: User): string | null { 215 + return user.canvas_refresh_token 216 + ? decrypt(user.canvas_refresh_token) 217 + : null; 218 + }, 219 + 220 + // Log API usage 221 + logUsage(userId: number, endpoint: string) { 222 + db.run( 223 + "INSERT INTO usage_logs (user_id, endpoint, timestamp) VALUES (?, ?, ?)", 224 + [userId, endpoint, Date.now()] 225 + ); 226 + }, 227 + 228 + // Get usage stats for user 229 + getUsageStats(userId: number, since?: number) { 230 + const query = since 231 + ? "SELECT * FROM usage_logs WHERE user_id = ? AND timestamp >= ?" 232 + : "SELECT * FROM usage_logs WHERE user_id = ?"; 233 + 234 + const params = since ? [userId, since] : [userId]; 235 + return db.query(query).all(...params); 236 + }, 237 + 238 + // Update last used timestamp 239 + updateLastUsed(userId: number) { 240 + db.run("UPDATE users SET last_used_at = ? WHERE id = ?", [ 241 + Date.now(), 242 + userId, 243 + ]); 244 + }, 245 + 246 + // Regenerate API key 247 + async regenerateApiKey(userId: number): Promise<string> { 248 + const newApiKey = generateApiKey(); 249 + const hashedApiKey = await hashApiKey(newApiKey); 250 + 251 + db.run("UPDATE users SET mcp_api_key = ? WHERE id = ?", [ 252 + hashedApiKey, 253 + userId, 254 + ]); 255 + 256 + return newApiKey; 257 + }, 258 + 259 + // Session management 260 + createSession(sessionId: string, data: { 261 + user_id?: number; 262 + canvas_domain: string; 263 + state: string; 264 + api_key?: string; 265 + maxAge: number; // in seconds 266 + }) { 267 + const now = Date.now(); 268 + db.run( 269 + `INSERT INTO sessions (id, user_id, canvas_domain, state, api_key, created_at, expires_at) 270 + VALUES (?, ?, ?, ?, ?, ?, ?)`, 271 + [ 272 + sessionId, 273 + data.user_id || null, 274 + data.canvas_domain, 275 + data.state, 276 + data.api_key || null, 277 + now, 278 + now + data.maxAge * 1000, 279 + ] 280 + ); 281 + }, 282 + 283 + getSession(sessionId: string) { 284 + // Clean up expired sessions 285 + db.run("DELETE FROM sessions WHERE expires_at < ?", [Date.now()]); 286 + 287 + return db 288 + .query("SELECT * FROM sessions WHERE id = ? AND expires_at > ?") 289 + .get(sessionId, Date.now()) as any; 290 + }, 291 + 292 + updateSession(sessionId: string, data: Partial<{ user_id: number; api_key: string }>) { 293 + const updates: string[] = []; 294 + const values: any[] = []; 295 + 296 + if (data.user_id !== undefined) { 297 + updates.push("user_id = ?"); 298 + values.push(data.user_id); 299 + } 300 + if (data.api_key !== undefined) { 301 + updates.push("api_key = ?"); 302 + values.push(data.api_key); 303 + } 304 + 305 + if (updates.length > 0) { 306 + values.push(sessionId); 307 + db.run( 308 + `UPDATE sessions SET ${updates.join(", ")} WHERE id = ?`, 309 + values 310 + ); 311 + } 312 + }, 313 + 314 + deleteSession(sessionId: string) { 315 + db.run("DELETE FROM sessions WHERE id = ?", [sessionId]); 316 + }, 317 + 318 + clearApiKeyFromSession(sessionId: string) { 319 + db.run("UPDATE sessions SET api_key = NULL WHERE id = ?", [sessionId]); 320 + }, 321 + 322 + // Get session by API key (for MCP authentication) 323 + getSessionByToken(token: string) { 324 + // Clean up expired sessions 325 + db.run("DELETE FROM sessions WHERE expires_at < ?", [Date.now()]); 326 + 327 + return db 328 + .query("SELECT * FROM sessions WHERE api_key = ? AND expires_at > ?") 329 + .get(token, Date.now()) as any; 330 + }, 331 + }; 332 + 333 + export default DB;
+245
src/lib/mcp-server.ts
··· 1 + import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 + import { 3 + CallToolRequestSchema, 4 + ListToolsRequestSchema, 5 + Tool, 6 + } from "@modelcontextprotocol/sdk/types.js"; 7 + import { z } from "zod"; 8 + import { CanvasClient } from "./canvas.js"; 9 + import DB from "./db.js"; 10 + 11 + // Create MCP Server instance with user context 12 + export function createMcpServer(userId: number): Server { 13 + const server = new Server( 14 + { 15 + name: "canvas-mcp", 16 + version: "1.0.0", 17 + }, 18 + { 19 + capabilities: { 20 + tools: {}, 21 + }, 22 + } 23 + ); 24 + 25 + // Register handlers with user context 26 + registerHandlers(server, userId); 27 + 28 + return server; 29 + } 30 + 31 + function registerHandlers(mcpServer: Server, userId: number) { 32 + 33 + // Define tool schemas 34 + const listCoursesSchema = z.object({ 35 + enrollment_state: z 36 + .enum(["active", "completed", "invited", "rejected"]) 37 + .optional(), 38 + }); 39 + 40 + const getAssignmentSchema = z.object({ 41 + course_id: z.number(), 42 + assignment_id: z.number(), 43 + }); 44 + 45 + const getAnnouncementsSchema = z.object({ 46 + course_id: z.number().optional(), 47 + limit: z.number().min(1).max(50).optional(), 48 + }); 49 + 50 + const getGradesSchema = z.object({ 51 + course_id: z.number().optional(), 52 + }); 53 + 54 + // Tool definitions 55 + const tools: Tool[] = [ 56 + { 57 + name: "list_courses", 58 + description: 59 + "List Canvas courses for the authenticated user. Can filter by enrollment state (active, completed, invited, rejected).", 60 + inputSchema: { 61 + type: "object", 62 + properties: { 63 + enrollment_state: { 64 + type: "string", 65 + enum: ["active", "completed", "invited", "rejected"], 66 + description: "Filter courses by enrollment state", 67 + }, 68 + }, 69 + }, 70 + }, 71 + { 72 + name: "get_assignment", 73 + description: 74 + "Get detailed information about a specific assignment including description, due date, points, and submission details.", 75 + inputSchema: { 76 + type: "object", 77 + properties: { 78 + course_id: { 79 + type: "number", 80 + description: "The Canvas course ID", 81 + }, 82 + assignment_id: { 83 + type: "number", 84 + description: "The Canvas assignment ID", 85 + }, 86 + }, 87 + required: ["course_id", "assignment_id"], 88 + }, 89 + }, 90 + { 91 + name: "get_upcoming_assignments", 92 + description: 93 + "Get upcoming assignments and deadlines for the next 30 days across all courses. Returns assignments with due dates, to-do items, and calendar events.", 94 + inputSchema: { 95 + type: "object", 96 + properties: {}, 97 + }, 98 + }, 99 + { 100 + name: "get_announcements", 101 + description: 102 + "Get course announcements. Can retrieve announcements from a specific course or across all courses, sorted by most recent first.", 103 + inputSchema: { 104 + type: "object", 105 + properties: { 106 + course_id: { 107 + type: "number", 108 + description: "Optional course ID to get announcements from a specific course. If not provided, returns announcements from all courses.", 109 + }, 110 + limit: { 111 + type: "number", 112 + description: "Maximum number of announcements to return (1-50). Default is 10.", 113 + }, 114 + }, 115 + }, 116 + }, 117 + { 118 + name: "get_grades", 119 + description: 120 + "Get grades and submission information. Can retrieve grades for a specific course (including individual assignment submissions) or overall grades across all courses.", 121 + inputSchema: { 122 + type: "object", 123 + properties: { 124 + course_id: { 125 + type: "number", 126 + description: "Optional course ID to get detailed grades and submissions for a specific course. If not provided, returns summary grades for all courses.", 127 + }, 128 + }, 129 + }, 130 + }, 131 + ]; 132 + 133 + // Register list_tools handler 134 + mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { 135 + return { tools }; 136 + }); 137 + 138 + // Register call_tool handler 139 + mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { 140 + const { name, arguments: args } = request.params; 141 + 142 + // Get user and Canvas token from database 143 + const userData = DB.raw 144 + .query("SELECT * FROM users WHERE id = ?") 145 + .get(userId) as any; 146 + 147 + if (!userData) { 148 + throw new Error("User not found"); 149 + } 150 + 151 + const canvasToken = DB.getCanvasToken(userData); 152 + const client = new CanvasClient(userData.canvas_domain, canvasToken); 153 + 154 + // Log usage 155 + DB.logUsage(userId, name); 156 + DB.updateLastUsed(userId); 157 + 158 + try { 159 + switch (name) { 160 + case "list_courses": { 161 + const params = listCoursesSchema.parse(args); 162 + const courses = await client.listCourses(params); 163 + return { 164 + content: [ 165 + { 166 + type: "text", 167 + text: JSON.stringify(courses, null, 2), 168 + }, 169 + ], 170 + }; 171 + } 172 + 173 + case "get_assignment": { 174 + const params = getAssignmentSchema.parse(args); 175 + const assignment = await client.getAssignment( 176 + params.course_id, 177 + params.assignment_id 178 + ); 179 + return { 180 + content: [ 181 + { 182 + type: "text", 183 + text: JSON.stringify(assignment, null, 2), 184 + }, 185 + ], 186 + }; 187 + } 188 + 189 + case "get_upcoming_assignments": { 190 + const upcoming = await client.getUpcomingAssignments(); 191 + return { 192 + content: [ 193 + { 194 + type: "text", 195 + text: JSON.stringify(upcoming, null, 2), 196 + }, 197 + ], 198 + }; 199 + } 200 + 201 + case "get_announcements": { 202 + const params = getAnnouncementsSchema.parse(args); 203 + const announcements = await client.getCourseAnnouncements( 204 + params.course_id, 205 + params.limit 206 + ); 207 + return { 208 + content: [ 209 + { 210 + type: "text", 211 + text: JSON.stringify(announcements, null, 2), 212 + }, 213 + ], 214 + }; 215 + } 216 + 217 + case "get_grades": { 218 + const params = getGradesSchema.parse(args); 219 + const grades = await client.getGradesAndSubmissions(params.course_id); 220 + return { 221 + content: [ 222 + { 223 + type: "text", 224 + text: JSON.stringify(grades, null, 2), 225 + }, 226 + ], 227 + }; 228 + } 229 + 230 + default: 231 + throw new Error(`Unknown tool: ${name}`); 232 + } 233 + } catch (error: any) { 234 + return { 235 + content: [ 236 + { 237 + type: "text", 238 + text: `Error: ${error.message}`, 239 + }, 240 + ], 241 + isError: true, 242 + }; 243 + } 244 + }); 245 + }
+91
src/lib/mcp-transport.ts
··· 1 + import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 + import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; 3 + import { createMcpServer } from "./mcp-server.js"; 4 + import DB from "./db.js"; 5 + 6 + // Handle MCP Streamable HTTP requests 7 + export async function handleMcpRequest( 8 + req: Request, 9 + apiToken?: string 10 + ): Promise<Response> { 11 + // Validate API token 12 + if (!apiToken) { 13 + return new Response( 14 + JSON.stringify({ 15 + jsonrpc: "2.0", 16 + error: { 17 + code: -32001, 18 + message: "Missing API token. Please authenticate first.", 19 + }, 20 + id: null, 21 + }), 22 + { 23 + status: 401, 24 + headers: { 25 + "Content-Type": "application/json", 26 + "WWW-Authenticate": `Bearer realm="Canvas MCP Server"`, 27 + }, 28 + } 29 + ); 30 + } 31 + 32 + // Look up user by API key 33 + const user = await DB.getUserByApiKey(apiToken); 34 + if (!user) { 35 + return new Response( 36 + JSON.stringify({ 37 + jsonrpc: "2.0", 38 + error: { 39 + code: -32001, 40 + message: "Invalid or expired API token", 41 + }, 42 + id: null, 43 + }), 44 + { 45 + status: 401, 46 + headers: { "Content-Type": "application/json" }, 47 + } 48 + ); 49 + } 50 + 51 + try { 52 + // Create stateless transport (new transport per request) 53 + const transport = new WebStandardStreamableHTTPServerTransport({ 54 + sessionIdGenerator: undefined, // Stateless mode 55 + }); 56 + 57 + // Create MCP server instance with user context 58 + const server = createMcpServer(user.id); 59 + 60 + // Connect server to transport 61 + await server.connect(transport); 62 + 63 + // Handle the request through transport 64 + return await transport.handleRequest(req); 65 + } catch (error: any) { 66 + return new Response( 67 + JSON.stringify({ 68 + jsonrpc: "2.0", 69 + error: { 70 + code: -32603, 71 + message: error.message || "Internal server error", 72 + }, 73 + id: null, 74 + }), 75 + { 76 + status: 500, 77 + headers: { "Content-Type": "application/json" }, 78 + } 79 + ); 80 + } 81 + } 82 + 83 + // Protected Resource Metadata for OAuth discovery 84 + export function getProtectedResourceMetadata(baseUrl: string) { 85 + return { 86 + resource: baseUrl, 87 + authorization_servers: [`${baseUrl}/auth`], 88 + bearer_methods_supported: ["header"], 89 + scopes_supported: ["canvas:read"], 90 + }; 91 + }
+463
src/public/dashboard.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Dashboard - Canvas MCP</title> 7 + <style> 8 + * { 9 + margin: 0; 10 + padding: 0; 11 + box-sizing: border-box; 12 + } 13 + 14 + body { 15 + font-family: system-ui, -apple-system, sans-serif; 16 + line-height: 1.6; 17 + max-width: 900px; 18 + margin: 2rem auto; 19 + padding: 2rem; 20 + color: #111; 21 + } 22 + 23 + header { 24 + display: flex; 25 + justify-content: space-between; 26 + align-items: center; 27 + margin-bottom: 2rem; 28 + padding-bottom: 1rem; 29 + border-bottom: 1px solid #ddd; 30 + } 31 + 32 + h1 { 33 + font-size: 1.75rem; 34 + font-weight: 600; 35 + } 36 + 37 + button { 38 + padding: 0.5rem 1rem; 39 + background: #333; 40 + color: white; 41 + border: none; 42 + border-radius: 4px; 43 + font-size: 0.9rem; 44 + cursor: pointer; 45 + } 46 + 47 + button:hover { 48 + background: #111; 49 + } 50 + 51 + .logout-btn { 52 + background: #c33; 53 + } 54 + 55 + .logout-btn:hover { 56 + background: #a22; 57 + } 58 + 59 + section { 60 + margin: 2rem 0; 61 + padding: 1.5rem; 62 + border: 1px solid #ddd; 63 + border-radius: 4px; 64 + } 65 + 66 + h2 { 67 + font-size: 1.25rem; 68 + margin-bottom: 1rem; 69 + font-weight: 600; 70 + } 71 + 72 + .info-grid { 73 + display: grid; 74 + gap: 0.75rem; 75 + } 76 + 77 + .info-row { 78 + display: grid; 79 + grid-template-columns: 150px 1fr; 80 + padding: 0.5rem 0; 81 + border-bottom: 1px solid #eee; 82 + } 83 + 84 + .info-row:last-child { 85 + border-bottom: none; 86 + } 87 + 88 + .label { 89 + font-weight: 500; 90 + color: #555; 91 + } 92 + 93 + .value { 94 + color: #111; 95 + font-family: monospace; 96 + font-size: 0.95rem; 97 + } 98 + 99 + .api-key-section { 100 + background: #f9f9f9; 101 + padding: 1rem; 102 + border-radius: 4px; 103 + } 104 + 105 + .api-key-display { 106 + display: flex; 107 + gap: 0.5rem; 108 + margin: 1rem 0; 109 + } 110 + 111 + .api-key-value { 112 + flex: 1; 113 + padding: 0.75rem; 114 + background: white; 115 + border: 1px solid #ddd; 116 + border-radius: 4px; 117 + font-family: monospace; 118 + font-size: 0.9rem; 119 + overflow-x: auto; 120 + white-space: nowrap; 121 + } 122 + 123 + .api-key-value:hover { 124 + background: #f8f9fa; 125 + } 126 + 127 + .api-key-value.hidden { 128 + filter: blur(6px); 129 + user-select: none; 130 + } 131 + 132 + #mcpUrlValue { 133 + user-select: all; 134 + } 135 + 136 + .btn-group { 137 + display: flex; 138 + gap: 0.5rem; 139 + flex-wrap: wrap; 140 + } 141 + 142 + .config-block { 143 + background: #2d2d2d; 144 + color: #f0f0f0; 145 + padding: 1rem; 146 + border-radius: 4px; 147 + font-family: monospace; 148 + font-size: 0.85rem; 149 + overflow-x: auto; 150 + margin-top: 1rem; 151 + white-space: pre; 152 + } 153 + 154 + .hidden { 155 + display: none; 156 + } 157 + 158 + .notification { 159 + position: fixed; 160 + bottom: 2rem; 161 + right: 2rem; 162 + background: #2d2d2d; 163 + color: white; 164 + padding: 1rem 1.5rem; 165 + border-radius: 4px; 166 + opacity: 0; 167 + transition: opacity 0.3s; 168 + pointer-events: none; 169 + } 170 + 171 + .notification.show { 172 + opacity: 1; 173 + } 174 + 175 + .stat-grid { 176 + display: grid; 177 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 178 + gap: 1rem; 179 + margin-top: 1rem; 180 + } 181 + 182 + .stat { 183 + text-align: center; 184 + padding: 1rem; 185 + background: #f9f9f9; 186 + border-radius: 4px; 187 + } 188 + 189 + .stat-value { 190 + font-size: 2rem; 191 + font-weight: bold; 192 + color: #0066cc; 193 + } 194 + 195 + .stat-label { 196 + color: #666; 197 + font-size: 0.9rem; 198 + margin-top: 0.25rem; 199 + } 200 + </style> 201 + </head> 202 + <body> 203 + <header> 204 + <h1>Dashboard</h1> 205 + <button class="logout-btn" id="logoutBtn">Logout</button> 206 + </header> 207 + 208 + <section> 209 + <h2>Account Information</h2> 210 + <div class="info-grid" id="accountInfo"> 211 + <div class="info-row"> 212 + <span class="label">Loading...</span> 213 + </div> 214 + </div> 215 + </section> 216 + 217 + <section> 218 + <h2>MCP Server Connection</h2> 219 + <p style="color: #666; margin-bottom: 1.5rem;"> 220 + Use these credentials to connect your MCP client (Claude Desktop, Cursor, etc.) to your Canvas account. 221 + </p> 222 + 223 + <!-- MCP Endpoint URL --> 224 + <div style="margin-bottom: 1.5rem;"> 225 + <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;"> 226 + <label style="font-weight: 600; color: #111;">MCP Server URL</label> 227 + <button id="copyUrlBtn" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Copy URL</button> 228 + </div> 229 + <div class="api-key-value" id="mcpUrlValue"> 230 + <span id="mcpUrl"></span> 231 + </div> 232 + <p style="font-size: 0.85rem; color: #666; margin-top: 0.5rem;"> 233 + This is your MCP server endpoint URL. You'll need this for Claude Desktop configuration. 234 + </p> 235 + </div> 236 + 237 + <!-- API Token --> 238 + <div class="api-key-section"> 239 + <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;"> 240 + <label style="font-weight: 600; color: #111;">API Token</label> 241 + <button id="regenerateBtn" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Regenerate Token</button> 242 + </div> 243 + 244 + <div id="apiKeyDisplay" style="display: none;"> 245 + <div style="background: #fff3cd; border: 1px solid #ffc107; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;"> 246 + <strong>⚠️ Save this token now!</strong> You won't be able to see it again after leaving this page. 247 + </div> 248 + <div class="api-key-display"> 249 + <div class="api-key-value" id="apiKeyValue"></div> 250 + <button id="copyKeyBtn">Copy Token</button> 251 + </div> 252 + </div> 253 + 254 + <div id="apiKeyHidden" style="display: none;"> 255 + <div class="api-key-display"> 256 + <div class="api-key-value" style="background: #f5f5f5; color: #999; user-select: none;"> 257 + •••••••••••••••••••••••••••••••••••••••• 258 + </div> 259 + </div> 260 + <p style="color: #666; margin-top: 0.5rem; font-size: 0.85rem;"> 261 + Your token is hidden for security. Click "Regenerate Token" above to create a new one. 262 + </p> 263 + </div> 264 + </div> 265 + </section> 266 + 267 + <section> 268 + <h2>Quick Setup</h2> 269 + <p style="color: #666; margin-bottom: 1rem;"> 270 + Add this configuration to your Claude Desktop config file: 271 + </p> 272 + <div style="background: #f9f9f9; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;"> 273 + <p style="font-size: 0.9rem; margin-bottom: 0.5rem;"> 274 + <strong>Config File Location:</strong> 275 + </p> 276 + <code style="display: block; padding: 0.5rem; background: white; border-radius: 4px; font-size: 0.85rem;"> 277 + ~/Library/Application Support/Claude/claude_desktop_config.json 278 + </code> 279 + </div> 280 + <pre class="config-block" id="configBlock"></pre> 281 + <button id="copyConfigBtn" style="margin-top: 1rem;">Copy Configuration</button> 282 + </section> 283 + 284 + <section> 285 + <h2>Usage Statistics</h2> 286 + <div class="stat-grid" id="usageStats"> 287 + <div class="stat"> 288 + <div class="stat-value">-</div> 289 + <div class="stat-label">Loading...</div> 290 + </div> 291 + </div> 292 + </section> 293 + 294 + <div class="notification" id="notification"></div> 295 + 296 + <script type="module"> 297 + let userData = null; 298 + let apiKeyVisible = false; 299 + 300 + async function loadDashboard() { 301 + try { 302 + const response = await fetch('/api/user/me', { 303 + credentials: 'include' 304 + }); 305 + 306 + if (!response.ok) { 307 + window.location.href = '/'; 308 + return; 309 + } 310 + 311 + userData = await response.json(); 312 + renderAccountInfo(); 313 + renderUsageStats(); 314 + setupApiKeyDisplay(); 315 + updateMCPConfig(); 316 + } catch (error) { 317 + console.error('Failed to load dashboard:', error); 318 + window.location.href = '/'; 319 + } 320 + } 321 + 322 + function renderAccountInfo() { 323 + const container = document.getElementById('accountInfo'); 324 + container.innerHTML = ` 325 + <div class="info-row"> 326 + <span class="label">Canvas Domain</span> 327 + <span class="value">${userData.canvas_domain}</span> 328 + </div> 329 + <div class="info-row"> 330 + <span class="label">Email</span> 331 + <span class="value">${userData.email || 'Not provided'}</span> 332 + </div> 333 + <div class="info-row"> 334 + <span class="label">Created</span> 335 + <span class="value">${new Date(userData.created_at).toLocaleDateString()}</span> 336 + </div> 337 + <div class="info-row"> 338 + <span class="label">Last Used</span> 339 + <span class="value">${userData.last_used_at ? new Date(userData.last_used_at).toLocaleDateString() : 'Never'}</span> 340 + </div> 341 + `; 342 + } 343 + 344 + function renderUsageStats() { 345 + const container = document.getElementById('usageStats'); 346 + const stats = userData.usage_stats || {}; 347 + container.innerHTML = ` 348 + <div class="stat"> 349 + <div class="stat-value">${stats.total_requests || 0}</div> 350 + <div class="stat-label">Total Requests</div> 351 + </div> 352 + <div class="stat"> 353 + <div class="stat-value">${stats.requests_24h || 0}</div> 354 + <div class="stat-label">Last 24 Hours</div> 355 + </div> 356 + <div class="stat"> 357 + <div class="stat-value">${stats.requests_7d || 0}</div> 358 + <div class="stat-label">Last 7 Days</div> 359 + </div> 360 + `; 361 + } 362 + 363 + function updateMCPConfig() { 364 + const token = apiKeyVisible ? userData.api_key : "YOUR_MCP_TOKEN_HERE"; 365 + const config = { 366 + mcpServers: { 367 + canvas: { 368 + url: `${window.location.origin}/mcp`, 369 + headers: { 370 + "Authorization": `Bearer ${token}` 371 + } 372 + } 373 + } 374 + }; 375 + 376 + document.getElementById('configBlock').textContent = JSON.stringify(config, null, 2); 377 + } 378 + 379 + function showNotification(message) { 380 + const notification = document.getElementById('notification'); 381 + notification.textContent = message; 382 + notification.classList.add('show'); 383 + setTimeout(() => { 384 + notification.classList.remove('show'); 385 + }, 2000); 386 + } 387 + 388 + function setupApiKeyDisplay() { 389 + // Always show the MCP URL 390 + const mcpUrl = `${window.location.origin}/mcp`; 391 + document.getElementById('mcpUrl').textContent = mcpUrl; 392 + 393 + if (userData.api_key) { 394 + // Show the key (first time only) 395 + document.getElementById('apiKeyDisplay').style.display = 'block'; 396 + document.getElementById('apiKeyValue').textContent = userData.api_key; 397 + apiKeyVisible = true; 398 + updateMCPConfig(); 399 + } else { 400 + // Hide the key 401 + document.getElementById('apiKeyHidden').style.display = 'block'; 402 + } 403 + } 404 + 405 + document.getElementById('copyUrlBtn').addEventListener('click', async () => { 406 + const mcpUrl = `${window.location.origin}/mcp`; 407 + await navigator.clipboard.writeText(mcpUrl); 408 + showNotification('MCP URL copied to clipboard'); 409 + }); 410 + 411 + document.getElementById('copyKeyBtn').addEventListener('click', async () => { 412 + await navigator.clipboard.writeText(userData.api_key); 413 + showNotification('API token copied to clipboard'); 414 + }); 415 + 416 + document.getElementById('copyConfigBtn').addEventListener('click', async () => { 417 + const configText = document.getElementById('configBlock').textContent; 418 + await navigator.clipboard.writeText(configText); 419 + showNotification('Configuration copied to clipboard'); 420 + }); 421 + 422 + document.getElementById('regenerateBtn').addEventListener('click', async () => { 423 + if (!confirm('Regenerate API token? This will invalidate your current token and disconnect all MCP clients.')) { 424 + return; 425 + } 426 + 427 + try { 428 + const response = await fetch('/api/user/regenerate-key', { 429 + method: 'POST', 430 + credentials: 'include' 431 + }); 432 + 433 + const data = await response.json(); 434 + userData.api_key = data.api_key; 435 + 436 + // Show the new key 437 + document.getElementById('apiKeyHidden').style.display = 'none'; 438 + document.getElementById('apiKeyDisplay').style.display = 'block'; 439 + document.getElementById('apiKeyValue').textContent = userData.api_key; 440 + apiKeyVisible = true; 441 + updateMCPConfig(); 442 + 443 + // Hide test result 444 + document.getElementById('testResult').style.display = 'none'; 445 + 446 + showNotification('API token regenerated successfully'); 447 + } catch (error) { 448 + alert('Failed to regenerate API token'); 449 + } 450 + }); 451 + 452 + document.getElementById('logoutBtn').addEventListener('click', async () => { 453 + await fetch('/api/auth/logout', { 454 + method: 'POST', 455 + credentials: 'include' 456 + }); 457 + window.location.href = '/'; 458 + }); 459 + 460 + loadDashboard(); 461 + </script> 462 + </body> 463 + </html>
+263
src/public/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Canvas MCP Server</title> 7 + <style> 8 + * { 9 + margin: 0; 10 + padding: 0; 11 + box-sizing: border-box; 12 + } 13 + 14 + body { 15 + font-family: system-ui, -apple-system, sans-serif; 16 + line-height: 1.6; 17 + max-width: 600px; 18 + margin: 4rem auto; 19 + padding: 2rem; 20 + color: #111; 21 + } 22 + 23 + h1 { 24 + font-size: 2rem; 25 + margin-bottom: 0.5rem; 26 + font-weight: 600; 27 + } 28 + 29 + p { 30 + color: #555; 31 + margin-bottom: 2rem; 32 + } 33 + 34 + section { 35 + margin: 2rem 0; 36 + padding: 1.5rem; 37 + border: 1px solid #ddd; 38 + border-radius: 4px; 39 + } 40 + 41 + section h2 { 42 + font-size: 1.1rem; 43 + margin-bottom: 1rem; 44 + font-weight: 600; 45 + } 46 + 47 + ul { 48 + list-style: none; 49 + padding-left: 1rem; 50 + } 51 + 52 + li { 53 + padding: 0.25rem 0; 54 + color: #555; 55 + } 56 + 57 + li::before { 58 + content: "→ "; 59 + margin-right: 0.5rem; 60 + } 61 + 62 + .login-form { 63 + margin-top: 2rem; 64 + } 65 + 66 + label { 67 + display: block; 68 + margin-bottom: 0.5rem; 69 + font-weight: 500; 70 + color: #333; 71 + } 72 + 73 + input { 74 + width: 100%; 75 + padding: 0.75rem; 76 + border: 1px solid #ddd; 77 + border-radius: 4px; 78 + font-size: 1rem; 79 + font-family: inherit; 80 + } 81 + 82 + input:focus { 83 + outline: none; 84 + border-color: #0066cc; 85 + } 86 + 87 + button { 88 + width: 100%; 89 + margin-top: 1rem; 90 + padding: 0.75rem; 91 + background: #0066cc; 92 + color: white; 93 + border: none; 94 + border-radius: 4px; 95 + font-size: 1rem; 96 + font-weight: 500; 97 + cursor: pointer; 98 + } 99 + 100 + button:hover { 101 + background: #0052a3; 102 + } 103 + 104 + button:disabled { 105 + background: #ccc; 106 + cursor: not-allowed; 107 + } 108 + 109 + .error { 110 + margin-top: 1rem; 111 + padding: 0.75rem; 112 + background: #fee; 113 + border: 1px solid #fcc; 114 + border-radius: 4px; 115 + color: #c33; 116 + display: none; 117 + } 118 + 119 + .error.show { 120 + display: block; 121 + } 122 + 123 + footer { 124 + margin-top: 4rem; 125 + padding-top: 2rem; 126 + border-top: 1px solid #eee; 127 + text-align: center; 128 + color: #999; 129 + font-size: 0.9rem; 130 + } 131 + </style> 132 + </head> 133 + <body> 134 + <header> 135 + <h1>Canvas MCP Server</h1> 136 + <p>Connect your Canvas LMS to AI assistants via the Model Context Protocol</p> 137 + </header> 138 + 139 + <section> 140 + <h2>Features</h2> 141 + <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> 145 + <li>Works with any Canvas institution</li> 146 + </ul> 147 + </section> 148 + 149 + <section class="login-form"> 150 + <h2>Get Started</h2> 151 + <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> 163 + <input 164 + type="password" 165 + id="accessToken" 166 + name="accessToken" 167 + placeholder="Get this from Canvas → Settings → New Access Token" 168 + autocomplete="off" 169 + required 170 + /> 171 + 172 + <button type="submit">Connect Canvas</button> 173 + <div id="error" class="error"></div> 174 + </form> 175 + 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> 189 + </section> 190 + 191 + <footer> 192 + <p>Canvas MCP Server &middot; <a href="https://modelcontextprotocol.io">MCP Documentation</a></p> 193 + </footer> 194 + 195 + <script type="module"> 196 + // Check if already logged in 197 + fetch('/api/user/me', { credentials: 'include' }) 198 + .then(r => { 199 + if (r.ok) { 200 + window.location.href = '/dashboard'; 201 + } 202 + }) 203 + .catch(() => {}); 204 + 205 + const form = document.getElementById('loginForm'); 206 + const errorDiv = document.getElementById('error'); 207 + 208 + function showError(message) { 209 + errorDiv.textContent = message; 210 + errorDiv.classList.add('show'); 211 + } 212 + 213 + function hideError() { 214 + errorDiv.classList.remove('show'); 215 + } 216 + 217 + form.addEventListener('submit', async (e) => { 218 + e.preventDefault(); 219 + hideError(); 220 + 221 + const domain = document.getElementById('canvasDomain').value.trim(); 222 + const token = document.getElementById('accessToken').value.trim(); 223 + 224 + if (!domain || !token) { 225 + showError('Please fill in all fields'); 226 + return; 227 + } 228 + 229 + if (!domain.includes('.')) { 230 + showError('Please enter a valid domain (e.g., canvas.university.edu)'); 231 + return; 232 + } 233 + 234 + const submitBtn = form.querySelector('button'); 235 + submitBtn.disabled = true; 236 + submitBtn.textContent = 'Connecting...'; 237 + 238 + try { 239 + const response = await fetch('/api/auth/token-login', { 240 + method: 'POST', 241 + headers: { 'Content-Type': 'application/json' }, 242 + body: JSON.stringify({ 243 + canvas_domain: domain, 244 + access_token: token 245 + }) 246 + }); 247 + 248 + const data = await response.json(); 249 + 250 + if (!response.ok) { 251 + throw new Error(data.error || 'Failed to connect'); 252 + } 253 + 254 + window.location.href = '/dashboard'; 255 + } catch (error) { 256 + showError(error.message); 257 + submitBtn.disabled = false; 258 + submitBtn.textContent = 'Connect Canvas'; 259 + } 260 + }); 261 + </script> 262 + </body> 263 + </html>