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 +21 -0
- package/README.md +29 -3
- package/dist/cli.js +0 -0
- package/dist/setup.js +530 -77
- package/dist/tools/analytics.js +3 -3
- package/dist/tools/courses.js +39 -7
- package/dist/tools/enrollments.js +10 -3
- package/package.json +1 -1
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
|
-
##
|
|
5
|
+
## Setup
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
// ──
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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("
|
|
248
|
-
|
|
249
|
-
|
|
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 (
|
|
253
|
-
console.log("
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
console.log(green(" ✓") + "
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
console.log("
|
|
273
|
-
|
|
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("
|
|
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
|
}
|
package/dist/tools/analytics.js
CHANGED
|
@@ -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 }) => {
|
package/dist/tools/courses.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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) },
|