canvas-agent 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hugh Koeze
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR DELICT OR OTHERWISE,
20
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
21
+ DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -2,11 +2,25 @@
2
2
 
3
3
  MCP server that connects Claude AI to Instructure Canvas LMS. Manage courses, assignments, grades, and more through natural language.
4
4
 
5
- ## Quick Setup
5
+ ## Setup
6
6
 
7
- Full setup guide: **[hughsibbele.github.io/Canvas-Agent](https://hughsibbele.github.io/Canvas-Agent)**
7
+ **New to the terminal or don't have Node.js yet?** Follow the full step-by-step walkthrough at **[hughsibbele.github.io/Canvas-Agent](https://hughsibbele.github.io/Canvas-Agent)** — it explains every step.
8
8
 
9
- If you already have Claude Code and Node.js installed:
9
+ ### Prerequisites
10
+
11
+ You need these installed before running Canvas Agent:
12
+
13
+ 1. **A Claude Pro subscription** ($20/month) — [sign up at claude.ai/pricing](https://claude.ai/pricing)
14
+ 2. **Node.js** — [download the LTS installer from nodejs.org](https://nodejs.org) and click through the defaults.
15
+ 3. **Claude Code and/or Claude Desktop** — Canvas Agent works in either, and if you install both the wizard will set up both:
16
+ - **Claude Code** (terminal-based): in a terminal, run
17
+ ```bash
18
+ npm install -g @anthropic-ai/claude-code
19
+ ```
20
+ Then type `claude` once to sign in with your Claude account.
21
+ - **Claude Desktop** (point-and-click app): [download from claude.ai/download](https://claude.ai/download)
22
+
23
+ ### Run the setup wizard
10
24
 
11
25
  ```bash
12
26
  npx -y canvas-agent setup
@@ -14,6 +28,18 @@ npx -y canvas-agent setup
14
28
 
15
29
  The wizard will walk you through connecting your Canvas account.
16
30
 
31
+ ### Alternative: install via Homebrew (macOS, developers)
32
+
33
+ If you already use [Homebrew](https://brew.sh) and would rather manage these as casks and formulas, you can replace the prerequisites above with:
34
+
35
+ ```bash
36
+ brew install node
37
+ brew install --cask claude-code # Claude Code (CLI)
38
+ brew install --cask claude # Claude Desktop (GUI app)
39
+ ```
40
+
41
+ Skip the ones you don't want — Canvas Agent only needs one of `claude-code` or `claude` to function. Homebrew is entirely optional; the nodejs.org installer path above works without it.
42
+
17
43
  ## What It Does
18
44
 
19
45
  Canvas Agent gives Claude access to your Canvas LMS:
package/dist/cli.js CHANGED
File without changes
package/dist/setup.js CHANGED
@@ -4,9 +4,9 @@
4
4
  * Uses only Node.js built-ins — no external dependencies.
5
5
  */
6
6
  import { createInterface } from "readline/promises";
7
- import { execSync } from "child_process";
7
+ import { execSync, execFileSync } from "child_process";
8
8
  import { stdin, stdout, platform } from "process";
9
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, realpathSync, } from "fs";
10
10
  import { join } from "path";
11
11
  import { homedir } from "os";
12
12
  // ANSI color helpers
@@ -26,7 +26,7 @@ function banner() {
26
26
  console.log(" You'll need about 3 minutes and access to your");
27
27
  console.log(" Canvas account.\n");
28
28
  }
29
- function isClaudeInstalled() {
29
+ function isClaudeCodeInstalled() {
30
30
  try {
31
31
  execSync("claude --version", { stdio: "pipe" });
32
32
  return true;
@@ -35,17 +35,47 @@ function isClaudeInstalled() {
35
35
  return false;
36
36
  }
37
37
  }
38
- function openBrowser(url) {
38
+ // Detects whether Claude Desktop is installed by looking for the app bundle
39
+ // (macOS) or executable (Windows). No official Linux build exists, so Linux
40
+ // always returns false. We intentionally DON'T check for the config file's
41
+ // existence — a user might install Claude Desktop but never launch it, in
42
+ // which case the config directory doesn't exist yet. Checking the app
43
+ // itself is the authoritative signal.
44
+ function isClaudeDesktopInstalled() {
45
+ if (platform === "darwin") {
46
+ return (existsSync("/Applications/Claude.app") ||
47
+ existsSync(join(homedir(), "Applications/Claude.app")));
48
+ }
49
+ if (platform === "win32") {
50
+ const localAppData = process.env.LOCALAPPDATA;
51
+ if (localAppData) {
52
+ // Known Claude Desktop install paths on Windows.
53
+ if (existsSync(join(localAppData, "AnthropicClaude", "claude.exe")))
54
+ return true;
55
+ if (existsSync(join(localAppData, "Programs", "Claude", "Claude.exe")))
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+ return false;
61
+ }
62
+ // Opens a local folder in the OS file manager (Finder on macOS, Explorer on
63
+ // Windows, default on Linux). Uses execFileSync with an argv array so paths
64
+ // with spaces, quotes, or other special characters work correctly.
65
+ function openInFileManager(path) {
39
66
  try {
40
- const cmd = platform === "darwin"
41
- ? "open"
42
- : platform === "win32"
43
- ? "start"
44
- : "xdg-open";
45
- execSync(`${cmd} "${url}"`, { stdio: "ignore" });
67
+ if (platform === "darwin") {
68
+ execFileSync("open", [path], { stdio: "ignore" });
69
+ }
70
+ else if (platform === "win32") {
71
+ execFileSync("explorer", [path], { stdio: "ignore" });
72
+ }
73
+ else {
74
+ execFileSync("xdg-open", [path], { stdio: "ignore" });
75
+ }
46
76
  }
47
77
  catch {
48
- // Silently failwe print the URL as fallback
78
+ // Nice-to-have, not critical ignore if it fails.
49
79
  }
50
80
  }
51
81
  function normalizeCanvasUrl(raw) {
@@ -79,13 +109,316 @@ async function validateCredentials(apiUrl, token) {
79
109
  return { valid: false, error: e.message };
80
110
  }
81
111
  }
82
- function registerWithClaudeCode(apiUrl, token) {
112
+ // Verifies that the URL the user typed actually points to a real Canvas
113
+ // school BEFORE we tell them to visit the URL to generate a token. This
114
+ // protects users from typo-squatters — e.g. someone who types
115
+ // "instructure.org" instead of "instructure.com" would otherwise be steered
116
+ // to a scam domain to generate a token, and their browser might pick up a
117
+ // push-notification spam prompt in the process.
118
+ //
119
+ // Strategy: hit /api/v1/users/self without a token and check the response.
120
+ // Canvas's actual unauthenticated response is 401 + the `x-canvas-meta`
121
+ // header. A *nonexistent* school on instructure.com returns 404 + the same
122
+ // header with a `"domain not found"` body — still instructure infrastructure,
123
+ // but no such school. A typosquat like .org doesn't hit instructure at all
124
+ // and fails at the socket layer. Each case gets a tailored error message.
125
+ async function checkCanvasReachability(apiUrl) {
126
+ // Normalize the display URL for error messages — the host the user sees.
127
+ const displayHost = apiUrl.replace(/\/api\/v1\/?$/, "");
128
+ // Hit an API endpoint directly rather than the root. The root redirects
129
+ // to an SSO OAuth flow that returns 405 for unauthenticated HEAD requests,
130
+ // which is unhelpful. The /users/self endpoint is stable and returns a
131
+ // clear 401 for unauthenticated callers on every real Canvas instance.
132
+ const endpoint = apiUrl.replace(/\/$/, "") + "/users/self";
133
+ // Abort after 5 seconds so the wizard doesn't hang on a dead hostname
134
+ // or a slow link.
135
+ const controller = new AbortController();
136
+ const timeout = setTimeout(() => controller.abort(), 5000);
83
137
  try {
84
- execSync(`claude mcp add -s user -e "CANVAS_API_URL=${apiUrl}" -e "CANVAS_API_TOKEN=${token}" canvas-agent -- npx -y canvas-agent`, { stdio: "inherit" });
85
- return true;
138
+ const res = await fetch(endpoint, { signal: controller.signal });
139
+ const isCanvasInfra = res.headers.get("x-canvas-meta") !== null;
140
+ // The happy path: real Canvas school, no token → 401 + x-canvas-meta.
141
+ if (res.status === 401 && isCanvasInfra) {
142
+ return { ok: true };
143
+ }
144
+ // 404 + x-canvas-meta means "we're on instructure.com but this specific
145
+ // school subdomain doesn't exist" — usually a typo in the school name.
146
+ if (res.status === 404 && isCanvasInfra) {
147
+ return {
148
+ ok: false,
149
+ error: `Canvas couldn't find a school at "${displayHost}".\n` +
150
+ ` Double-check the spelling of your school's subdomain.`,
151
+ };
152
+ }
153
+ // Anything else — unexpected status, or no x-canvas-meta header at all.
154
+ // This is the dangerous case: a URL that responds but isn't Canvas.
155
+ return {
156
+ ok: false,
157
+ error: `"${displayHost}" responded, but it doesn't look like a Canvas site.\n` +
158
+ ` Double-check the spelling. A common mistake is typing ".org" instead\n` +
159
+ ` of ".com" — that can send you to a lookalike scam domain.`,
160
+ };
161
+ }
162
+ catch (e) {
163
+ const cause = e.cause?.code || e.code || "";
164
+ // Any network-level failure — DNS NXDOMAIN, TLS error, socket refused,
165
+ // etc. Treat them all the same: the user's URL doesn't reach a real
166
+ // server, and a typo in the TLD is the most common cause.
167
+ if (cause === "ENOTFOUND" ||
168
+ cause === "UND_ERR_SOCKET" ||
169
+ cause === "ECONNREFUSED" ||
170
+ cause === "EAI_AGAIN") {
171
+ return {
172
+ ok: false,
173
+ error: `Could not reach "${displayHost}".\n` +
174
+ ` Double-check the spelling — a common mistake is typing ".org" or ".net"\n` +
175
+ ` instead of ".com".`,
176
+ };
177
+ }
178
+ if (e.name === "AbortError") {
179
+ return {
180
+ ok: false,
181
+ error: `"${displayHost}" didn't respond in time. Check your internet connection.`,
182
+ };
183
+ }
184
+ return {
185
+ ok: false,
186
+ error: `Could not reach "${displayHost}" — ${e.message || "unknown network error"}.`,
187
+ };
188
+ }
189
+ finally {
190
+ clearTimeout(timeout);
191
+ }
192
+ }
193
+ function registerWithClaudeCode(apiUrl, token, scopeChoice) {
194
+ // For local scope, run `claude` from inside the target folder so it writes
195
+ // the config under that folder's project key. For user scope, cwd doesn't
196
+ // matter — user-scope entries aren't tied to a directory.
197
+ const spawnOpts = { stdio: "pipe" };
198
+ if (scopeChoice.scope === "local") {
199
+ spawnOpts.cwd = scopeChoice.folderPath;
200
+ }
201
+ // If canvas-agent is already registered where we're about to write (e.g.
202
+ // the user re-runs setup after refreshing their token), remove the old
203
+ // entry first so add-json doesn't fail with "already exists" and we don't
204
+ // leave stale credentials on disk.
205
+ try {
206
+ execFileSync("claude", ["mcp", "get", "canvas-agent"], spawnOpts);
207
+ try {
208
+ execFileSync("claude", ["mcp", "remove", "canvas-agent"], spawnOpts);
209
+ }
210
+ catch {
211
+ // Best effort — if remove fails, add-json below will surface the real error.
212
+ }
86
213
  }
87
214
  catch {
88
- return false;
215
+ // Not registered in this scope; nothing to remove.
216
+ }
217
+ // Use `claude mcp add-json` with an argv array (not a shell string) so that:
218
+ // - Token characters never need shell escaping
219
+ // - We sidestep the variadic `-e` parsing quirk in `claude mcp add` where
220
+ // the server name can be swallowed as if it were another env var
221
+ const serverConfig = {
222
+ command: "npx",
223
+ args: ["-y", "canvas-agent"],
224
+ env: {
225
+ CANVAS_API_URL: apiUrl,
226
+ CANVAS_API_TOKEN: token,
227
+ },
228
+ };
229
+ try {
230
+ execFileSync("claude", [
231
+ "mcp",
232
+ "add-json",
233
+ "-s",
234
+ scopeChoice.scope,
235
+ "canvas-agent",
236
+ JSON.stringify(serverConfig),
237
+ ], spawnOpts);
238
+ }
239
+ catch (e) {
240
+ const stderr = (e.stderr?.toString() ||
241
+ e.stdout?.toString() ||
242
+ e.message ||
243
+ "unknown error").trim();
244
+ return { ok: false, error: stderr };
245
+ }
246
+ // Trust but verify — confirm the entry actually landed. This catches the
247
+ // rare case where `add-json` exits 0 but the config wasn't written (stale
248
+ // CLI version, file permission quirks, etc.).
249
+ try {
250
+ execFileSync("claude", ["mcp", "get", "canvas-agent"], spawnOpts);
251
+ return { ok: true };
252
+ }
253
+ catch {
254
+ return {
255
+ ok: false,
256
+ error: "Claude Code accepted the configuration but the server isn't showing up in 'claude mcp list'. Try re-running setup, or report this at https://github.com/hughsibbele/Canvas-Agent/issues",
257
+ };
258
+ }
259
+ }
260
+ // ── Scope selection helpers ───────────────────────────────────────────
261
+ // These walk a non-technical user through choosing WHERE to install Canvas
262
+ // Agent (globally vs. a specific folder) and, if needed, creating a folder
263
+ // for them. Every prompt has a sensible default and plain-language guidance.
264
+ async function chooseScope(rl, alsoInstallingInDesktop) {
265
+ // When Desktop is also a target, the heading and intro need to make clear
266
+ // that this scope question only affects Claude Code — in Claude Desktop,
267
+ // Canvas Agent is always globally available and we can't narrow that.
268
+ if (alsoInstallingInDesktop) {
269
+ console.log(bold(" Step 3: Where to Use Canvas Agent in Claude Code\n"));
270
+ console.log(" " +
271
+ dim("(This choice only affects Claude Code. In Claude Desktop,"));
272
+ console.log(" " + dim("Canvas Agent is always available globally.)") + "\n");
273
+ console.log(" Canvas Agent can be available every time you use Claude Code,");
274
+ console.log(" or only when you're working in a specific folder.\n");
275
+ }
276
+ else {
277
+ console.log(bold(" Step 3: Where to Use Canvas Agent\n"));
278
+ console.log(" Canvas Agent can be available every time you use Claude,");
279
+ console.log(" or only when you're working in a specific folder.\n");
280
+ }
281
+ console.log(" " + bold("1.") + " Everywhere " + dim("(recommended)"));
282
+ console.log(" " + dim("Canvas tools will load every time you run Claude."));
283
+ console.log(" " + dim("Pick this if you're not sure."));
284
+ console.log();
285
+ console.log(" " + bold("2.") + " Only in a specific folder");
286
+ console.log(" " + dim("Canvas tools will only load when you run Claude from"));
287
+ console.log(" " + dim("that folder. Good if you want to keep your Canvas"));
288
+ console.log(" " + dim("work separate from other projects."));
289
+ console.log();
290
+ while (true) {
291
+ const answer = (await rl.question(" Choose " + bold("1") + " or " + bold("2") + " (press Enter for 1): ")).trim();
292
+ if (answer === "" || answer === "1") {
293
+ console.log();
294
+ return { scope: "user" };
295
+ }
296
+ if (answer === "2") {
297
+ console.log();
298
+ const folderPath = await chooseOrCreateFolder(rl);
299
+ return { scope: "local", folderPath };
300
+ }
301
+ console.log(red(" ✗") + " Please type 1 or 2.\n");
302
+ }
303
+ }
304
+ async function chooseOrCreateFolder(rl) {
305
+ console.log(bold(" Pick a Folder for Canvas Agent\n"));
306
+ console.log(" " + bold("1.") + " Create a new folder for me " + dim("(recommended)"));
307
+ console.log(" " + dim('We\'ll make a "Canvas Work" folder inside your Documents.'));
308
+ console.log();
309
+ console.log(" " + bold("2.") + " I already have a folder I want to use");
310
+ console.log(" " + dim("You'll type the full path to it."));
311
+ console.log();
312
+ while (true) {
313
+ const answer = (await rl.question(" Choose " + bold("1") + " or " + bold("2") + " (press Enter for 1): ")).trim();
314
+ if (answer === "" || answer === "1") {
315
+ console.log();
316
+ return await createNewFolder(rl);
317
+ }
318
+ if (answer === "2") {
319
+ console.log();
320
+ return await pickExistingFolder(rl);
321
+ }
322
+ console.log(red(" ✗") + " Please type 1 or 2.\n");
323
+ }
324
+ }
325
+ async function createNewFolder(rl) {
326
+ const defaultName = "Canvas Work";
327
+ const parentDir = join(homedir(), "Documents");
328
+ console.log(" We'll create a folder inside your Documents folder:");
329
+ console.log(" " + dim(parentDir) + "\n");
330
+ let name = "";
331
+ while (true) {
332
+ const input = (await rl.question(` Folder name (press Enter for "${defaultName}"): `)).trim();
333
+ name = input || defaultName;
334
+ // Basic sanity check — no path separators in the name, and not empty.
335
+ if (name.includes("/") || name.includes("\\")) {
336
+ console.log(red(" ✗") + ' Folder name cannot contain "/" or "\\\\". Try again.\n');
337
+ continue;
338
+ }
339
+ break;
340
+ }
341
+ // Ensure ~/Documents exists. On macOS/Windows it virtually always does,
342
+ // but on Linux or exotic setups it might not — recursive mkdir is safe.
343
+ try {
344
+ mkdirSync(parentDir, { recursive: true });
345
+ }
346
+ catch {
347
+ // If this fails the folder create below will also fail and surface the real error.
348
+ }
349
+ const folderPath = join(parentDir, name);
350
+ if (existsSync(folderPath)) {
351
+ console.log(green(" ✓") + " Folder already exists — we'll use it.");
352
+ }
353
+ else {
354
+ try {
355
+ mkdirSync(folderPath, { recursive: true });
356
+ console.log(green(" ✓") + " Created folder.");
357
+ }
358
+ catch (e) {
359
+ throw new Error(`Could not create folder "${folderPath}": ${e.message}`);
360
+ }
361
+ }
362
+ // Resolve symlinks so the path we use matches the key Claude Code will
363
+ // store (e.g. /tmp → /private/tmp on macOS). Otherwise the local-scope
364
+ // config lookup could miss our entry.
365
+ const canonical = realpathSync(folderPath);
366
+ console.log(dim(` ${canonical}\n`));
367
+ // Nice touch: pop the folder open in Finder/Explorer so the user can
368
+ // literally see where it is. Fails silently if the OS command isn't available.
369
+ openInFileManager(canonical);
370
+ return canonical;
371
+ }
372
+ async function pickExistingFolder(rl) {
373
+ console.log(" Type the full path to the folder you want to use.");
374
+ console.log(" " + dim("You can use ~ to mean your home folder."));
375
+ console.log(" " + dim("Example: ~/Documents/My Canvas Courses") + "\n");
376
+ while (true) {
377
+ const rawPath = (await rl.question(" Folder path: ")).trim();
378
+ if (!rawPath) {
379
+ console.log(red(" ✗") + " Please type a folder path.\n");
380
+ continue;
381
+ }
382
+ // Expand ~ → home directory. Handles "~", "~/foo", and "~\foo".
383
+ let expanded = rawPath;
384
+ if (expanded === "~") {
385
+ expanded = homedir();
386
+ }
387
+ else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
388
+ expanded = join(homedir(), expanded.slice(2));
389
+ }
390
+ if (!existsSync(expanded)) {
391
+ console.log(red(" ✗") + " That folder doesn't exist: " + dim(expanded));
392
+ const create = (await rl.question(" Create it? (y/n): ")).trim().toLowerCase();
393
+ if (create !== "y") {
394
+ console.log();
395
+ continue;
396
+ }
397
+ try {
398
+ mkdirSync(expanded, { recursive: true });
399
+ console.log(green(" ✓") + " Created folder.");
400
+ }
401
+ catch (e) {
402
+ console.log(red(" ✗") + ` Could not create: ${e.message}\n`);
403
+ continue;
404
+ }
405
+ }
406
+ // Make sure it's a directory, not a regular file.
407
+ try {
408
+ const stat = statSync(expanded);
409
+ if (!stat.isDirectory()) {
410
+ console.log(red(" ✗") + " That path is a file, not a folder. Try again.\n");
411
+ continue;
412
+ }
413
+ }
414
+ catch (e) {
415
+ console.log(red(" ✗") + ` Could not read folder: ${e.message}\n`);
416
+ continue;
417
+ }
418
+ const canonical = realpathSync(expanded);
419
+ console.log(green(" ✓") + " Using folder.");
420
+ console.log(dim(` ${canonical}\n`));
421
+ return canonical;
89
422
  }
90
423
  }
91
424
  function getDesktopConfigPath() {
@@ -99,20 +432,34 @@ function getDesktopConfigPath() {
99
432
  }
100
433
  function registerWithDesktop(apiUrl, token) {
101
434
  const configPath = getDesktopConfigPath();
102
- if (!configPath)
103
- return false;
435
+ if (!configPath) {
436
+ return {
437
+ ok: false,
438
+ error: "No known Claude Desktop config path for this platform.",
439
+ };
440
+ }
104
441
  try {
105
- // Read existing config or start fresh
442
+ // Read existing config or start fresh. We preserve every other field
443
+ // in the Desktop config (preferences, other MCP servers, etc.) — we
444
+ // only touch mcpServers["canvas-agent"].
106
445
  let config = {};
107
446
  if (existsSync(configPath)) {
108
- config = JSON.parse(readFileSync(configPath, "utf-8"));
447
+ const raw = readFileSync(configPath, "utf-8");
448
+ try {
449
+ config = JSON.parse(raw);
450
+ }
451
+ catch (e) {
452
+ return {
453
+ ok: false,
454
+ error: `Could not parse existing Claude Desktop config: ${e.message}. Fix or delete ${configPath} and try again.`,
455
+ };
456
+ }
109
457
  }
110
458
  else {
111
- // Ensure parent directory exists
459
+ // Ensure parent directory exists (Claude Desktop installed but never launched)
112
460
  const dir = join(configPath, "..");
113
461
  mkdirSync(dir, { recursive: true });
114
462
  }
115
- // Merge in our MCP server entry
116
463
  if (!config.mcpServers)
117
464
  config.mcpServers = {};
118
465
  config.mcpServers["canvas-agent"] = {
@@ -124,10 +471,10 @@ function registerWithDesktop(apiUrl, token) {
124
471
  },
125
472
  };
126
473
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
127
- return true;
474
+ return { ok: true };
128
475
  }
129
- catch {
130
- return false;
476
+ catch (e) {
477
+ return { ok: false, error: e.message || "unknown error writing Desktop config" };
131
478
  }
132
479
  }
133
480
  function printManualConfig(apiUrl, token) {
@@ -149,26 +496,42 @@ export async function runSetup() {
149
496
  const rl = createInterface({ input: stdin, output: stdout });
150
497
  try {
151
498
  banner();
152
- // ── Step 1: Check for Claude Code ──
153
- const hasClaude = isClaudeInstalled();
154
- let useDesktop = false;
155
- if (hasClaude) {
156
- console.log(green(" ✓") + " Claude Code is installed.\n");
157
- }
158
- else {
159
- console.log(yellow(" ⚠") + " Claude Code not found.\n");
160
- console.log(" Canvas Agent works best with Claude Code.");
161
- console.log(" To install it, run:\n");
162
- console.log(bold(" npm install -g @anthropic-ai/claude-code\n"));
163
- console.log(" Then run this setup again.\n");
164
- const answer = await rl.question(" Continue with Claude Desktop setup instead? (y/n): ");
165
- if (answer.trim().toLowerCase() !== "y") {
166
- console.log("\n No problem! Install Claude Code and run this again:");
167
- console.log(bold(" npx -y canvas-agent setup\n"));
168
- return;
499
+ // ── Detection: Claude Code and Claude Desktop are independent targets ──
500
+ //
501
+ // We install into whichever are present — no fall-through logic, no
502
+ // "do you want Desktop instead" prompt. If the user has both, Canvas
503
+ // Agent gets set up in both automatically. If they have only one, we
504
+ // use that one. If they have neither, we bail out with a clear install
505
+ // guide.
506
+ const hasClaudeCode = isClaudeCodeInstalled();
507
+ const hasClaudeDesktop = isClaudeDesktopInstalled();
508
+ console.log(" Checking your Claude setup...\n");
509
+ console.log((hasClaudeCode ? green(" ✓") : dim(" ✗")) +
510
+ " Claude Code " +
511
+ (hasClaudeCode ? "" : dim("(not found)")));
512
+ console.log((hasClaudeDesktop ? green(" ✓") : dim("")) +
513
+ " Claude Desktop " +
514
+ (hasClaudeDesktop ? "" : dim("(not found)")));
515
+ console.log();
516
+ if (!hasClaudeCode && !hasClaudeDesktop) {
517
+ console.log(yellow(" ⚠") + " Canvas Agent needs either Claude Code or Claude Desktop.");
518
+ console.log();
519
+ console.log(" " + bold("Install one (or both) and then re-run this wizard:"));
520
+ console.log();
521
+ console.log(" " + bold("Claude Code") + dim(" — terminal-based, works in any folder"));
522
+ console.log(" " + bold("npm install -g @anthropic-ai/claude-code"));
523
+ if (platform === "darwin") {
524
+ console.log(" " + dim("or, with Homebrew:") + " " + bold("brew install --cask claude-code"));
169
525
  }
170
- useDesktop = true;
171
526
  console.log();
527
+ console.log(" " + bold("Claude Desktop") + dim(" — point-and-click app"));
528
+ console.log(" Download at " + cyan("https://claude.ai/download"));
529
+ console.log();
530
+ console.log(" Full walkthrough: " + cyan("https://hughsibbele.github.io/Canvas-Agent"));
531
+ console.log();
532
+ console.log(" When you're ready, re-run:");
533
+ console.log(bold(" npx -y canvas-agent setup\n"));
534
+ return;
172
535
  }
173
536
  // ── Step 2: Get Canvas URL ──
174
537
  console.log(bold(" Step 1: Your School's Canvas\n"));
@@ -186,21 +549,38 @@ export async function runSetup() {
186
549
  continue;
187
550
  }
188
551
  console.log(dim(` → ${apiUrl}`));
552
+ console.log(dim(" Checking that this is a real Canvas site..."));
553
+ // Verify the URL actually points to a Canvas instance BEFORE we tell
554
+ // the user to visit it. This catches typos like .org instead of .com
555
+ // (which can send users to scam domains) before any browser navigation
556
+ // or token generation happens.
557
+ const reachability = await checkCanvasReachability(apiUrl);
558
+ if (!reachability.ok) {
559
+ console.log(red(" ✗") + ` ${reachability.error}\n`);
560
+ continue;
561
+ }
562
+ console.log(green(" ✓") + " Canvas found.");
189
563
  break;
190
564
  }
191
565
  // ── Step 3: Get Canvas API Token ──
192
566
  console.log(bold("\n Step 2: Canvas API Token\n"));
193
567
  console.log(" We need an access token from Canvas. Here's how:\n");
194
- console.log(` 1. Log in to Canvas at ${cyan(`https://${hostname}`)}`);
195
- console.log(" 2. Click your profile picture (top left) → " + bold("Settings"));
196
- console.log(" 3. Scroll down to " + bold('"Approved Integrations"'));
197
- console.log(" 4. Click " + bold('"+ New Access Token"'));
198
- console.log(" 5. For Purpose, type: " + dim("Canvas Agent"));
199
- console.log(" 6. Click " + bold('"Generate Token"') + " and copy the token shown\n");
200
568
  const settingsUrl = `https://${hostname}/profile/settings`;
201
- console.log(` Opening ${cyan(settingsUrl)} in your browser...`);
202
- openBrowser(settingsUrl);
203
- console.log();
569
+ // We intentionally DO NOT auto-open this URL in the user's browser.
570
+ // Auto-opening a URL the user just typed means a typo can steer their
571
+ // browser directly into a scam domain (push-notification spam, fake
572
+ // captchas, etc.) before they have a chance to notice. Instead, print
573
+ // the URL, let the user see it, and let them click or copy it. The
574
+ // reachability check above also guarantees this is really Canvas —
575
+ // but showing the URL gives the user a final visual confirmation.
576
+ console.log(" 1. Open this link in your browser:");
577
+ console.log(" " + cyan(settingsUrl));
578
+ console.log(" " + dim("(Most terminals let you Cmd+click the link. Otherwise, copy and paste it.)"));
579
+ console.log(" 2. Scroll down to " + bold('"Approved Integrations"'));
580
+ console.log(" 3. Click " + bold('"+ New Access Token"'));
581
+ console.log(" 4. For Purpose, type: " + dim("Canvas Agent"));
582
+ console.log(" 5. Click " + bold('"Generate Token"') + " and copy the token shown");
583
+ console.log(" 6. Come back here and paste the token below\n");
204
584
  let token = "";
205
585
  while (true) {
206
586
  token = (await rl.question(" Paste your token here: ")).trim();
@@ -233,51 +613,124 @@ export async function runSetup() {
233
613
  console.log();
234
614
  }
235
615
  }
236
- // ── Step 5: Register MCP server ──
237
- console.log(bold(" Step 3: Connecting to Claude\n"));
238
- let registered = false;
239
- if (!useDesktop) {
240
- // Try Claude Code first
241
- console.log(" Registering Canvas Agent with Claude Code...\n");
242
- registered = registerWithClaudeCode(apiUrl, token);
243
- if (registered) {
244
- console.log(green("\n ✓") + " Registered with Claude Code!\n");
616
+ // ── Step 3: Choose scope (Claude Code targets only) ──
617
+ //
618
+ // Scope is a Claude Code concept — Claude Desktop's config is a single
619
+ // global file with no per-folder behavior. So we only ask when Claude
620
+ // Code is one of the install targets. When Desktop is *also* a target,
621
+ // chooseScope() shows an extra note so the user understands the choice
622
+ // only narrows Claude Code.
623
+ let scopeChoice = { scope: "user" };
624
+ if (hasClaudeCode) {
625
+ try {
626
+ scopeChoice = await chooseScope(rl, hasClaudeDesktop);
627
+ }
628
+ catch (e) {
629
+ console.log(red(" ✗") + ` ${e.message}\n`);
630
+ console.log(" Re-run this wizard when you're ready:");
631
+ console.log(bold(" npx -y canvas-agent setup\n"));
632
+ return;
633
+ }
634
+ }
635
+ // ── Step 4: Register MCP server in each detected target ──
636
+ // Step number depends on whether we asked about scope above. If we
637
+ // skipped scope (Desktop-only setup) this is Step 3; otherwise Step 4.
638
+ const connectStepNum = hasClaudeCode ? 4 : 3;
639
+ console.log(bold(` Step ${connectStepNum}: Connecting to Claude\n`));
640
+ // Each target is attempted independently. A failure in one doesn't
641
+ // abort the other — users should get whatever we can successfully
642
+ // install for them, and we report per-target results so they can see
643
+ // exactly what happened.
644
+ let codeResult = null;
645
+ let desktopResult = null;
646
+ if (hasClaudeCode) {
647
+ console.log(" Registering with Claude Code...");
648
+ codeResult = registerWithClaudeCode(apiUrl, token, scopeChoice);
649
+ if (codeResult.ok) {
650
+ console.log(green(" ✓") + " Claude Code\n");
245
651
  }
246
652
  else {
247
- console.log(yellow("\n ⚠") + " Could not register automatically.\n");
248
- // Fall through to Desktop or manual
249
- useDesktop = true;
653
+ console.log(yellow(" ⚠") + " Claude Code — could not register.");
654
+ const detail = codeResult.error
655
+ .split("\n")
656
+ .map((l) => ` ${l}`)
657
+ .join("\n");
658
+ console.log(dim(detail) + "\n");
250
659
  }
251
660
  }
252
- if (useDesktop && !registered) {
253
- console.log(" Setting up Claude Desktop...");
254
- registered = registerWithDesktop(apiUrl, token);
255
- if (registered) {
256
- console.log(green(" ✓") + " Configured Claude Desktop!\n");
257
- console.log(dim(" Restart Claude Desktop for changes to take effect.\n"));
661
+ if (hasClaudeDesktop) {
662
+ console.log(" Configuring Claude Desktop...");
663
+ desktopResult = registerWithDesktop(apiUrl, token);
664
+ if (desktopResult.ok) {
665
+ console.log(green(" ✓") + " Claude Desktop\n");
666
+ }
667
+ else {
668
+ console.log(yellow(" ⚠") + " Claude Desktop — could not configure.");
669
+ const detail = desktopResult.error
670
+ .split("\n")
671
+ .map((l) => ` ${l}`)
672
+ .join("\n");
673
+ console.log(dim(detail) + "\n");
258
674
  }
259
675
  }
260
- if (!registered) {
676
+ const codeOk = codeResult?.ok ?? false;
677
+ const desktopOk = desktopResult?.ok ?? false;
678
+ // If NOTHING succeeded, fall back to printing the config for manual install.
679
+ if (!codeOk && !desktopOk) {
680
+ console.log(red(" ✗") + " Canvas Agent could not be installed automatically.\n");
261
681
  printManualConfig(apiUrl, token);
262
682
  console.log(" Copy the JSON above and add it to your Claude configuration.");
263
683
  console.log(" For help, visit: " + cyan("https://hughsibbele.github.io/Canvas-Agent") + "\n");
684
+ return;
264
685
  }
265
686
  // ── Done ──
266
687
  console.log(cyan(" ╔══════════════════════════════════════╗"));
267
688
  console.log(cyan(" ║") + green(" Setup Complete! ") + cyan("║"));
268
689
  console.log(cyan(" ╚══════════════════════════════════════╝"));
269
690
  console.log();
270
- if (!useDesktop) {
271
- console.log(" To start using Canvas Agent:");
272
- console.log(" 1. Open a terminal and type: " + bold("claude"));
273
- console.log(' 2. Try asking: ' + dim('"List my Canvas courses"'));
691
+ // Summary of where Canvas Agent ended up
692
+ if (codeOk && desktopOk) {
693
+ console.log(bold(" Canvas Agent is installed in both Claude Code and Claude Desktop."));
694
+ }
695
+ else if (codeOk) {
696
+ console.log(bold(" Canvas Agent is installed in Claude Code."));
274
697
  }
275
698
  else {
276
- console.log(" To start using Canvas Agent:");
277
- console.log(" 1. Restart Claude Desktop");
278
- console.log(' 2. Try asking: ' + dim('"List my Canvas courses"'));
699
+ console.log(bold(" Canvas Agent is installed in Claude Desktop."));
279
700
  }
280
701
  console.log();
702
+ // Per-target launch instructions. We show every surface that succeeded
703
+ // so the user knows how to reach Canvas Agent from whichever tool they
704
+ // prefer to open first.
705
+ if (codeOk) {
706
+ console.log(" " + bold("To use it in Claude Code:"));
707
+ if (scopeChoice.scope === "user") {
708
+ console.log(" 1. Open Terminal and type: " + bold("claude"));
709
+ console.log(' 2. Try asking: ' + dim('"List my Canvas courses"'));
710
+ }
711
+ else {
712
+ // Local scope — user needs to launch claude from the registered folder.
713
+ const folderPath = scopeChoice.folderPath;
714
+ console.log(" Canvas Agent is installed in this folder:");
715
+ console.log(" " + cyan(folderPath));
716
+ console.log();
717
+ console.log(" 1. Open Terminal");
718
+ console.log(" 2. Go to your Canvas folder:");
719
+ console.log(" " + bold(`cd "${folderPath}"`));
720
+ console.log(" 3. Type: " + bold("claude"));
721
+ console.log(' 4. Try asking: ' + dim('"List my Canvas courses"'));
722
+ console.log();
723
+ console.log(" " + dim("Tip: copy-paste the whole thing —"));
724
+ console.log(" " + dim(`cd "${folderPath}" && claude`));
725
+ }
726
+ console.log();
727
+ }
728
+ if (desktopOk) {
729
+ console.log(" " + bold("To use it in Claude Desktop:"));
730
+ console.log(" 1. Quit and restart Claude Desktop if it's running");
731
+ console.log(' 2. Try asking: ' + dim('"List my Canvas courses"'));
732
+ console.log();
733
+ }
281
734
  console.log(" For help: " + cyan("https://hughsibbele.github.io/Canvas-Agent"));
282
735
  console.log();
283
736
  }
@@ -9,7 +9,7 @@ export function registerAnalyticsTools(server) {
9
9
  content: [{ type: "text", text: JSON.stringify(activity, null, 2) }],
10
10
  };
11
11
  });
12
- server.tool("get_course_assignment_analytics", "Get aggregate statistical analytics per assignment: min/max/median scores, submission counts (on_time, late, missing). This returns statistics, not the assignments themselves — use list_assignments for that.", {
12
+ server.tool("get_course_assignment_analytics", "Get aggregate statistical analytics per assignment: min/max/median scores, submission counts (on_time, late, missing). This returns statistics, not the assignments themselves — use list_assignments for that. NOTE: this endpoint returns LIFETIME data and does NOT support grading_period_id — for semester-scoped data, fall back to fetching submissions directly via list_submissions.", {
13
13
  course_id: z.string().describe("Canvas course ID"),
14
14
  }, async ({ course_id }) => {
15
15
  const analytics = await canvas(`/courses/${course_id}/analytics/assignments`);
@@ -17,7 +17,7 @@ export function registerAnalyticsTools(server) {
17
17
  content: [{ type: "text", text: JSON.stringify(analytics, null, 2) }],
18
18
  };
19
19
  });
20
- server.tool("get_student_summaries", "Get per-student engagement analytics for a course: page views, participations, and tardiness breakdown. For enrollment/roster data, use list_students instead.", {
20
+ server.tool("get_student_summaries", "Get per-student engagement analytics for a course: page views, participations, and tardiness breakdown (missing/late/on_time counts). For enrollment/roster data, use list_students instead. NOTE: this endpoint returns LIFETIME data and does NOT support grading_period_id — the tardiness counts include all assignments since the course began, not just the current semester. For per-semester counts, iterate course submissions with a grading_period_id filter.", {
21
21
  course_id: z.string().describe("Canvas course ID"),
22
22
  sort_column: z
23
23
  .enum([
@@ -48,7 +48,7 @@ export function registerAnalyticsTools(server) {
48
48
  content: [{ type: "text", text: JSON.stringify(activity, null, 2) }],
49
49
  };
50
50
  });
51
- server.tool("get_student_assignment_data", "Get per-assignment scores, submission status, and timestamps for a specific student. This is analytics data — for actual submission details, use list_submissions.", {
51
+ server.tool("get_student_assignment_data", "Get per-assignment scores, submission status, and timestamps for a specific student. This is analytics data — for actual submission details, use list_submissions. NOTE: this endpoint returns LIFETIME data and does NOT support grading_period_id — it includes assignments from all grading periods.", {
52
52
  course_id: z.string().describe("Canvas course ID"),
53
53
  student_id: z.string().describe("Canvas user ID of the student"),
54
54
  }, async ({ course_id, student_id }) => {
@@ -1,16 +1,29 @@
1
1
  import { z } from "zod";
2
- import { canvasAll } from "../canvas-client.js";
2
+ import { canvas, canvasAll } from "../canvas-client.js";
3
3
  export function registerCourseTools(server) {
4
- server.tool("list_courses", "List Canvas courses you have access to. Returns course IDs needed by all other tools. Shows active courses by default.", {
4
+ server.tool("list_courses", "List Canvas courses you have access to. Returns course IDs needed by all other tools. Shows active courses by default. Admins can use search_term to search all courses in the account.", {
5
5
  enrollment_state: z
6
6
  .enum(["active", "completed", "invited"])
7
7
  .default("active")
8
8
  .describe("Filter by enrollment state"),
9
- }, async ({ enrollment_state }) => {
10
- const courses = await canvasAll("/courses", {
11
- enrollment_state,
12
- include: "term",
13
- });
9
+ search_term: z
10
+ .string()
11
+ .optional()
12
+ .describe("Search all courses by name or code (admin only). When provided, searches across the entire account instead of just enrolled courses."),
13
+ }, async ({ enrollment_state, search_term }) => {
14
+ let courses;
15
+ if (search_term) {
16
+ courses = await canvasAll("/accounts/self/courses", {
17
+ search_term,
18
+ include: "term",
19
+ });
20
+ }
21
+ else {
22
+ courses = await canvasAll("/courses", {
23
+ enrollment_state,
24
+ include: "term",
25
+ });
26
+ }
14
27
  const summary = courses.map((c) => ({
15
28
  id: c.id,
16
29
  name: c.name,
@@ -49,4 +62,23 @@ export function registerCourseTools(server) {
49
62
  content: [{ type: "text", text: JSON.stringify(modules, null, 2) }],
50
63
  };
51
64
  });
65
+ server.tool("list_grading_periods", "List the grading periods (e.g. semesters, quarters) defined for a course's term. Returns id, title, start_date, end_date, and is_closed for each period. The returned IDs can be passed as grading_period_id to get_student_enrollments and other grade-related tools to scope grades and submissions to a single period instead of the cumulative lifetime grade. Note: schools that don't use grading periods will get a single default period.", { course_id: z.string().describe("Canvas course ID") }, async ({ course_id }) => {
66
+ // The grading_periods endpoint returns a wrapped response:
67
+ // {"grading_periods": [...], "meta": {...}}
68
+ // Use canvas (not canvasAll) and unwrap manually.
69
+ const raw = await canvas(`/courses/${course_id}/grading_periods`);
70
+ const periods = (raw?.grading_periods ?? []);
71
+ const summary = periods.map((p) => ({
72
+ id: p.id,
73
+ title: p.title,
74
+ start_date: p.start_date,
75
+ end_date: p.end_date,
76
+ close_date: p.close_date,
77
+ is_closed: p.is_closed,
78
+ weight: p.weight,
79
+ }));
80
+ return {
81
+ content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
82
+ };
83
+ });
52
84
  }
@@ -49,11 +49,18 @@ export function registerEnrollmentTools(server) {
49
49
  content: [{ type: "text", text: JSON.stringify(sections, null, 2) }],
50
50
  };
51
51
  });
52
- server.tool("get_student_enrollments", "Get enrollment details for a specific student in a course, including grades if available.", {
52
+ server.tool("get_student_enrollments", "Get enrollment details for a specific student in a course, including grades if available. Pass grading_period_id to get the grade for a specific semester/term instead of the cumulative lifetime grade — find the id with list_grading_periods.", {
53
53
  course_id: z.string().describe("Canvas course ID"),
54
54
  student_id: z.string().describe("Canvas user ID of the student"),
55
- }, async ({ course_id, student_id }) => {
56
- const enrollments = await canvasAll(`/courses/${course_id}/enrollments`, { user_id: student_id });
55
+ grading_period_id: z
56
+ .string()
57
+ .optional()
58
+ .describe("Grading period ID to scope grades to a single semester/term. Without this, current_score reflects the entire course history. Use list_grading_periods to discover ids."),
59
+ }, async ({ course_id, student_id, grading_period_id }) => {
60
+ const params = { user_id: student_id };
61
+ if (grading_period_id)
62
+ params.grading_period_id = grading_period_id;
63
+ const enrollments = await canvasAll(`/courses/${course_id}/enrollments`, params);
57
64
  return {
58
65
  content: [
59
66
  { type: "text", text: JSON.stringify(enrollments, null, 2) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-agent",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for Instructure Canvas LMS — connect Claude AI to your courses",
5
5
  "type": "module",
6
6
  "bin": {