brightspace-mcp-server 3.9.16 → 3.9.18

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/README.md CHANGED
@@ -71,6 +71,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
71
71
  | Discussions | "Summarize the latest posts in the final project thread" |
72
72
  | Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura and YouTube) |
73
73
  | Calendar | "When is my midterm?" · "Is lab cancelled on Thursday?" |
74
+ | For instructors and TAs | "Which students haven't submitted Lab 4 yet?" · "What feedback did I leave on this student's homework?" · "Download that student's submitted PDF" (students see a clear "instructor access required" note) |
74
75
 
75
76
  ## Prompts
76
77
 
@@ -109,6 +110,7 @@ How often you're asked is up to your school, not this tool. Everything else abou
109
110
 
110
111
  ## Something not working?
111
112
 
113
+ - Run `npx -y brightspace-mcp-server@latest doctor` first — it checks your Node version, saved setup, credential store, Brightspace connectivity, saved sign-in, and installed version, and tells you exactly what to fix.
112
114
  - **It works in the terminal but not in the app:** the app starts it separately and may not see your password store yet — see [docs/troubleshooting.md](docs/troubleshooting.md).
113
115
  - **Which version do I have?** Ask your AI "which version of the Brightspace server am I running?"
114
116
  - **Still stuck?** [Open an issue](https://github.com/RohanMuppa/brightspace-mcp-server/issues) and paste what the terminal printed.
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Brightspace MCP Server
4
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
5
+ * Licensed under MIT — see LICENSE file for details.
6
+ *
7
+ * `doctor` — a beginner-facing diagnostic. It runs a fixed, ordered list of
8
+ * checks (Node version, saved setup, credential store, school reachability,
9
+ * saved sign-in, one real API call, installed version) and prints one line
10
+ * per check: a checkmark plus a short description, or a cross plus exactly
11
+ * one plain-English next step.
12
+ *
13
+ * Every check after "setup found" depends on the ones before it, so a
14
+ * missing config or a broken sign-in is reported once, with everything
15
+ * downstream marked "skipped" instead of printing a cascade of confusing
16
+ * errors for the same root cause.
17
+ *
18
+ * Like `setup` and `auth`, this never prints a secret: no password, token,
19
+ * or cookie value appears in any line.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import * as path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import * as os from "node:os";
25
+ import { configStoreExists, loadConfigStore, } from "./utils/config-store.js";
26
+ import { accountSessionDirectory, expandTilde, readEnvSecret, parseSessionCookieEnv } from "./utils/config.js";
27
+ import { getStoredPassword } from "./auth/credential-store.js";
28
+ import { discoverVersions } from "./api/version-discovery.js";
29
+ import { TokenManager } from "./auth/token-manager.js";
30
+ import { SessionStore } from "./auth/session-store.js";
31
+ import { D2LApiClient } from "./api/client.js";
32
+ import { fetchLatestVersion, isNewerVersion, safeVersionLabel, installKindOf, } from "./utils/update-checker.js";
33
+ import { SETUP_COMMAND, AUTH_COMMAND, GLOBAL_INSTALL_COMMAND, CLEAR_NPX_CACHE_COMMAND, } from "./utils/commands.js";
34
+ const __filename = fileURLToPath(import.meta.url);
35
+ const __dirname = path.dirname(__filename);
36
+ function readInstalledVersion() {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "..", "package.json"), "utf-8"));
39
+ return pkg.version ?? "0.0.0";
40
+ }
41
+ catch {
42
+ return "0.0.0";
43
+ }
44
+ }
45
+ /**
46
+ * doctor only ever reads a saved session — it must never write session.json,
47
+ * trash it, or take the write lock a v1-record migration uses (that lock can
48
+ * also throw AuthenticationInProgressError while a real sign-in holds it).
49
+ * SessionStore.peek() is the read-only sibling of load() that does none of
50
+ * that, so every write-shaped method here is a no-op and load() delegates to
51
+ * peek() instead of the real load(). Exported for tests, which pass a fake
52
+ * in place of a real SessionStore to prove the no-op wiring.
53
+ */
54
+ export function readOnlySessionStore(store) {
55
+ return {
56
+ load: () => store.peek(),
57
+ save: async () => { },
58
+ clear: async () => { },
59
+ saveIfCurrent: async () => true,
60
+ clearIfCurrent: async () => true,
61
+ };
62
+ }
63
+ /** A TokenManager that can mint from a saved cookie but can never write, clear, or migrate session.json. */
64
+ function readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie) {
65
+ return new TokenManager({
66
+ baseUrl,
67
+ envAccessToken,
68
+ envSessionCookie,
69
+ sessionStore: readOnlySessionStore(new SessionStore(sessionDir)),
70
+ });
71
+ }
72
+ function defaultDoctorDeps() {
73
+ return {
74
+ nodeVersion: process.version,
75
+ platform: process.platform,
76
+ env: process.env,
77
+ configStoreExists,
78
+ loadConfigStore,
79
+ getStoredPassword,
80
+ discoverVersions,
81
+ getSessionToken: (baseUrl, sessionDir, envAccessToken, envSessionCookie) => readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie).getToken(),
82
+ countCourses: async (baseUrl, sessionDir, envAccessToken, envSessionCookie) => {
83
+ const apiClient = new D2LApiClient({
84
+ baseUrl,
85
+ tokenManager: readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie),
86
+ });
87
+ const coursesPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3");
88
+ const response = await apiClient.get(coursesPath);
89
+ return { count: response?.Items?.length ?? 0, hasMore: Boolean(response?.PagingInfo?.HasMoreItems) };
90
+ },
91
+ fetchLatestVersion,
92
+ installKind: installKindOf(),
93
+ installedVersion: readInstalledVersion(),
94
+ };
95
+ }
96
+ function pass(id, message) {
97
+ return { id, ok: true, line: `✓ ${message}` };
98
+ }
99
+ function problem(id, message) {
100
+ return { id, ok: false, line: `✗ ${message}` };
101
+ }
102
+ // ── Individual checks ────────────────────────────────────────────────
103
+ function checkNode(deps) {
104
+ const major = Number(deps.nodeVersion.replace(/^v/, "").split(".")[0]);
105
+ if (Number.isFinite(major) && major >= 20) {
106
+ return pass("node", `Node.js ${deps.nodeVersion} (20 or newer)`);
107
+ }
108
+ return problem("node", `Node.js ${deps.nodeVersion} is too old — install Node 20 or newer from https://nodejs.org/, then try again.`);
109
+ }
110
+ /**
111
+ * Checks the resolved configuration — config.json AND the same env overrides
112
+ * loadConfig() honours (D2L_BASE_URL, D2L_USERNAME, D2L_ACCESS_TOKEN,
113
+ * D2L_SESSION_COOKIE) — since a Docker/headless setup legitimately has no
114
+ * config.json at all and is configured entirely through the environment.
115
+ * "Neither config.json nor the environment says anything" is the single most
116
+ * common first-run state for everyone else.
117
+ */
118
+ function checkConfig(deps) {
119
+ let store = null;
120
+ try {
121
+ if (deps.configStoreExists())
122
+ store = deps.loadConfigStore();
123
+ }
124
+ catch {
125
+ store = null;
126
+ }
127
+ let envAccessToken;
128
+ let envSessionCookie;
129
+ try {
130
+ envAccessToken = readEnvSecret(deps.env.D2L_ACCESS_TOKEN, "D2L_ACCESS_TOKEN");
131
+ const rawCookie = readEnvSecret(deps.env.D2L_SESSION_COOKIE, "D2L_SESSION_COOKIE");
132
+ envSessionCookie = rawCookie ? parseSessionCookieEnv(rawCookie) : undefined;
133
+ }
134
+ catch (error) {
135
+ return {
136
+ ok: false,
137
+ result: problem("config", error instanceof Error ? error.message : "D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE is malformed."),
138
+ };
139
+ }
140
+ const rawBaseUrl = deps.env.D2L_BASE_URL || store?.baseUrl;
141
+ const username = deps.env.D2L_USERNAME || store?.username;
142
+ const hasEnvCredential = Boolean(envAccessToken || envSessionCookie);
143
+ // A username is required only for the normal stored-password flow: an env
144
+ // credential signs in on its own and never needs one.
145
+ if (!rawBaseUrl || (!username && !hasEnvCredential)) {
146
+ return { ok: false, result: problem("config", `No setup found — run: ${SETUP_COMMAND}`) };
147
+ }
148
+ let baseUrl;
149
+ try {
150
+ baseUrl = new URL(rawBaseUrl).origin;
151
+ }
152
+ catch {
153
+ return { ok: false, result: problem("config", `The configured Brightspace URL is invalid — run: ${SETUP_COMMAND}`) };
154
+ }
155
+ const sessionRoot = deps.env.D2L_SESSION_DIR
156
+ ? expandTilde(deps.env.D2L_SESSION_DIR)
157
+ : store?.sessionDir
158
+ ? expandTilde(store.sessionDir)
159
+ : path.join(os.homedir(), ".d2l-session");
160
+ const sessionDir = accountSessionDirectory(sessionRoot, baseUrl, username);
161
+ const label = username ? `signed in as ${username} at ${baseUrl}` : `configured for ${baseUrl} via environment variables`;
162
+ return {
163
+ ok: true,
164
+ result: pass("config", `Setup found — ${label}`),
165
+ baseUrl,
166
+ username,
167
+ sessionDir,
168
+ envAccessToken,
169
+ envSessionCookie,
170
+ };
171
+ }
172
+ async function checkCredential(deps, baseUrl, username, envAccessToken, envSessionCookie) {
173
+ // Never print the value — only which variable is supplying it.
174
+ if (envAccessToken)
175
+ return pass("credential", "Signed in via the D2L_ACCESS_TOKEN environment variable (no password needed)");
176
+ if (envSessionCookie)
177
+ return pass("credential", "Signed in via the D2L_SESSION_COOKIE environment variable (no password needed)");
178
+ if (!username)
179
+ return problem("credential", `No username configured — run: ${SETUP_COMMAND}`);
180
+ try {
181
+ const password = await deps.getStoredPassword(baseUrl, username);
182
+ if (password)
183
+ return pass("credential", "Password saved in your operating system's credential store");
184
+ return problem("credential", `No saved password found for this account — run: ${SETUP_COMMAND}`);
185
+ }
186
+ catch (error) {
187
+ // On Linux the store itself (secret-tool / an unlocked Secret Service)
188
+ // is usually the problem, and the thrown error already explains that in
189
+ // plain English. Elsewhere, re-running setup is the one fix that covers
190
+ // every native-store failure a student could hit.
191
+ if (deps.platform === "linux") {
192
+ return problem("credential", error instanceof Error ? error.message : "The Linux credential store is locked or unavailable. Unlock your keyring and try again.");
193
+ }
194
+ return problem("credential", `Run setup again: ${SETUP_COMMAND}`);
195
+ }
196
+ }
197
+ async function checkNetwork(deps, baseUrl) {
198
+ try {
199
+ await deps.discoverVersions(baseUrl);
200
+ return pass("network", `${baseUrl} is reachable`);
201
+ }
202
+ catch {
203
+ return problem("network", `Could not reach ${baseUrl} — check your internet connection and the school address, then run doctor again.`);
204
+ }
205
+ }
206
+ async function checkSession(deps, baseUrl, sessionDir, envAccessToken, envSessionCookie) {
207
+ const failMessage = `No working saved sign-in — open your AI app and ask a question to sign in, or run: ${AUTH_COMMAND}`;
208
+ try {
209
+ const token = await deps.getSessionToken(baseUrl, sessionDir, envAccessToken, envSessionCookie);
210
+ if (token)
211
+ return { ok: true, result: pass("session", "Saved sign-in works — a token was issued without opening a browser") };
212
+ return { ok: false, result: problem("session", failMessage) };
213
+ }
214
+ catch {
215
+ return { ok: false, result: problem("session", failMessage) };
216
+ }
217
+ }
218
+ /**
219
+ * Trims an upstream error message down to something safe to show a beginner:
220
+ * a 403/404 body can be a whole HTML page, and either way the raw text can
221
+ * carry the endpoint URL. Collapses newlines, drops URLs, and caps length.
222
+ */
223
+ function sanitizeErrorDetail(message) {
224
+ const noUrls = message.replace(/https?:\/\/\S+/gi, "");
225
+ const singleLine = noUrls.replace(/\s+/g, " ").trim();
226
+ const LIMIT = 120;
227
+ return singleLine.length > LIMIT ? `${singleLine.slice(0, LIMIT).trimEnd()}…` : singleLine;
228
+ }
229
+ async function checkCourses(deps, baseUrl, sessionDir, envAccessToken, envSessionCookie) {
230
+ try {
231
+ const { count, hasMore } = await deps.countCourses(baseUrl, sessionDir, envAccessToken, envSessionCookie);
232
+ const label = hasMore ? `at least ${count}` : `${count}`;
233
+ return pass("courses", `Found ${label} course${count === 1 ? "" : "s"} on Brightspace`);
234
+ }
235
+ catch (error) {
236
+ const detail = error instanceof Error ? sanitizeErrorDetail(error.message) : "an unknown error";
237
+ return problem("courses", `Could not load your courses (${detail}) — run doctor again after checking your connection.`);
238
+ }
239
+ }
240
+ async function checkVersion(deps) {
241
+ let latest = null;
242
+ try {
243
+ latest = await deps.fetchLatestVersion();
244
+ }
245
+ catch {
246
+ latest = null;
247
+ }
248
+ const sourceNote = deps.installKind === "source-checkout" ? " (source checkout)" : "";
249
+ // Offline, or the registry hiccuped: never treat "couldn't check" as a
250
+ // failure — there is nothing actionable to tell a student here.
251
+ if (latest === null) {
252
+ return pass("version", `Running v${safeVersionLabel(deps.installedVersion)}${sourceNote} — could not check for a newer version (you may be offline)`);
253
+ }
254
+ if (!isNewerVersion(latest, deps.installedVersion)) {
255
+ return pass("version", `Running the latest version (v${safeVersionLabel(deps.installedVersion)})${sourceNote}`);
256
+ }
257
+ const from = safeVersionLabel(deps.installedVersion);
258
+ const to = safeVersionLabel(latest);
259
+ const nextStep = deps.installKind === "source-checkout"
260
+ ? "pull the latest changes and run npm run build"
261
+ : deps.installKind === "npm-install"
262
+ ? `run: ${GLOBAL_INSTALL_COMMAND}`
263
+ : `it updates automatically next time, or run: ${CLEAR_NPX_CACHE_COMMAND}`;
264
+ return problem("version", `A newer version is available (v${from} to v${to}) — ${nextStep}.`);
265
+ }
266
+ // ── Orchestration ────────────────────────────────────────────────────
267
+ /** Human-readable name for each check, used to prefix its "Skipped" line. */
268
+ const CHECK_NAMES = {
269
+ node: "Node.js version",
270
+ config: "Setup",
271
+ credential: "Credential store",
272
+ network: "Brightspace reachability",
273
+ session: "Saved sign-in",
274
+ courses: "Course list",
275
+ version: "Version",
276
+ };
277
+ const SKIPPED_SETUP = `finish setup first, then run doctor again: ${SETUP_COMMAND}`;
278
+ const SKIPPED_SESSION = "fix the saved sign-in step above first, then run doctor again.";
279
+ function skip(id, reason) {
280
+ return problem(id, `${CHECK_NAMES[id]}: Skipped — ${reason}`);
281
+ }
282
+ export async function runDoctorChecks(overrides = {}) {
283
+ const deps = { ...defaultDoctorDeps(), ...overrides };
284
+ const checks = [];
285
+ checks.push(checkNode(deps));
286
+ const config = checkConfig(deps);
287
+ checks.push(config.result);
288
+ if (!config.ok) {
289
+ checks.push(skip("credential", SKIPPED_SETUP));
290
+ checks.push(skip("network", SKIPPED_SETUP));
291
+ checks.push(skip("session", SKIPPED_SETUP));
292
+ checks.push(skip("courses", SKIPPED_SETUP));
293
+ }
294
+ else {
295
+ checks.push(await checkCredential(deps, config.baseUrl, config.username, config.envAccessToken, config.envSessionCookie));
296
+ checks.push(await checkNetwork(deps, config.baseUrl));
297
+ const session = await checkSession(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie);
298
+ checks.push(session.result);
299
+ if (session.ok) {
300
+ checks.push(await checkCourses(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie));
301
+ }
302
+ else {
303
+ checks.push(skip("courses", SKIPPED_SESSION));
304
+ }
305
+ }
306
+ checks.push(await checkVersion(deps));
307
+ return { checks, allOk: checks.every((c) => c.ok) };
308
+ }
309
+ // ── CLI entry point ──────────────────────────────────────────────────
310
+ async function main() {
311
+ const result = await runDoctorChecks();
312
+ console.log("");
313
+ console.log("Brightspace MCP Server — doctor");
314
+ console.log("");
315
+ for (const check of result.checks)
316
+ console.log(` ${check.line}`);
317
+ console.log("");
318
+ console.log(result.allOk
319
+ ? "Everything looks good — open your AI app and ask a question."
320
+ : "Fix the ✗ items above, then run doctor again.");
321
+ console.log("");
322
+ process.exitCode = result.allOk ? 0 : 1;
323
+ }
324
+ // VITEST is set only by the test runner, which imports this module for the
325
+ // functions above and must never run the real checks against this machine.
326
+ if (!process.env.VITEST) {
327
+ main().catch((err) => {
328
+ console.error("doctor failed:", err instanceof Error ? err.message : String(err));
329
+ process.exitCode = 1;
330
+ });
331
+ }
package/build/index.js CHANGED
@@ -16,7 +16,7 @@ import { startUpdateChecks } from "./utils/update-checker.js";
16
16
  import { readFileSync } from "node:fs";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { dirname, resolve } from "node:path";
19
- import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, } from "./tools/index.js";
19
+ import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, } from "./tools/index.js";
20
20
  import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
21
21
  const __filename = fileURLToPath(import.meta.url);
22
22
  const __dirname = dirname(__filename);
@@ -37,6 +37,9 @@ if (subcommand === 'setup') {
37
37
  else if (subcommand === 'auth') {
38
38
  await import('./auth-cli.js');
39
39
  }
40
+ else if (subcommand === 'doctor') {
41
+ await import('./doctor.js');
42
+ }
40
43
  else {
41
44
  // ── MCP Server (default) ────────────────────────────────────────────
42
45
  // CRITICAL: Enable stdout guard IMMEDIATELY to prevent corruption of stdio transport
@@ -135,7 +138,14 @@ else {
135
138
  registerGetVideoTranscript(server, apiClient);
136
139
  registerGetServerInfo(server, config, PKG_VERSION, apiClient);
137
140
  registerSearchCourse(server, apiClient);
138
- log("DEBUG", "MCP tools registered (19 tools)");
141
+ // Instructor / TA dropbox tools (read-only) — 403 for a student account.
142
+ registerGetDropboxFolders(server, apiClient);
143
+ registerGetDropboxSubmissions(server, apiClient);
144
+ registerGetDropboxUserSubmissions(server, apiClient);
145
+ registerGetDropboxFeedback(server, apiClient);
146
+ registerGetRubricsForObject(server, apiClient);
147
+ registerDownloadDropboxSubmissionFile(server, apiClient);
148
+ log("DEBUG", "MCP tools registered (25 tools)");
139
149
  // Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
140
150
  // picker, distinct from tools. Each one is a canned user message that
141
151
  // names our own tools by name, so a client with no idea what to ask
@@ -148,7 +158,7 @@ else {
148
158
  // Connect stdio transport
149
159
  const transport = new StdioServerTransport();
150
160
  await server.connect(transport);
151
- log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (19 tools, 4 prompts registered)");
161
+ log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (25 tools, 4 prompts registered)");
152
162
  log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
153
163
  }
154
164
  catch (error) {
package/build/setup.js CHANGED
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
  import { configStoreExists, getConfigStorePath, loadConfigStore, } from "./utils/config-store.js";
16
16
  import { saveSecureConfig } from "./utils/secure-config.js";
17
17
  import { writeFileAtomicSync } from "./utils/atomic-write.js";
18
- import { AUTH_COMMAND } from "./utils/commands.js";
18
+ import { AUTH_COMMAND, DOCTOR_COMMAND } from "./utils/commands.js";
19
19
  import { cliMcpClients, configureCliMcpClient, isCliAvailable, } from "./utils/mcp-client-cli.js";
20
20
  // ANSI helpers
21
21
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -387,11 +387,12 @@ async function main() {
387
387
  console.log("");
388
388
  }
389
389
  else {
390
+ console.log(dim(" Your school's Brightspace address — the web address you open to see your courses."));
390
391
  while (!baseUrl) {
391
- const raw = await ask(rl, "What is your Brightspace URL? (e.g., purdue.brightspace.com): ");
392
+ const raw = await ask(rl, "What is your Brightspace address? (e.g., purdue.brightspace.com): ");
392
393
  const normalized = normalizeUrl(raw);
393
394
  if (!raw || !isValidUrl(normalized)) {
394
- console.log(yellow(" Please enter a valid URL (e.g., purdue.brightspace.com)"));
395
+ console.log(yellow(" Please enter a valid address, like purdue.brightspace.com"));
395
396
  continue;
396
397
  }
397
398
  baseUrl = normalized;
@@ -416,10 +417,13 @@ async function main() {
416
417
  // ── Step 2: Username ─────────────────────────────────────────────
417
418
  const usernamePrompt = preset
418
419
  ? `What is your ${preset.usernameLabel}? `
419
- : "What is your Brightspace username? ";
420
+ : "What username do you use to sign in to Brightspace? ";
420
421
  if (preset?.usernameHint) {
421
422
  console.log(dim(` ${preset.usernameHint}`));
422
423
  }
424
+ else {
425
+ console.log(dim(" The same username you type on your school's Brightspace sign-in page."));
426
+ }
423
427
  let username = "";
424
428
  while (!username) {
425
429
  username = await ask(rl, usernamePrompt);
@@ -431,6 +435,7 @@ async function main() {
431
435
  // ── Step 3: Password (hidden) ────────────────────────────────────
432
436
  // Close the rl temporarily since askPassword manages its own
433
437
  rl.close();
438
+ console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
434
439
  const passwordPrompt = preset
435
440
  ? `What is your ${preset.name} password? `
436
441
  : "What is your Brightspace password? ";
@@ -449,20 +454,21 @@ async function main() {
449
454
  });
450
455
  const configuredClients = [];
451
456
  // ── Step 4: MFA info ─────────────────────────────────────────────
457
+ console.log(dim(" Your school may ask you to approve the sign-in a second way — on your phone, with a code, or something else."));
452
458
  if (preset) {
453
- console.log(dim(` MFA: ${preset.mfaNote}`));
459
+ console.log(dim(` ${preset.mfaNote}`));
454
460
  }
455
461
  else {
456
- console.log(dim(" MFA: You will be prompted to approve the sign-in on your phone during auth."));
462
+ console.log(dim(" You'll be prompted to approve the sign-in on your phone during auth."));
457
463
  }
458
464
  console.log("");
459
465
  // Only two outcomes exist: a hidden browser or a visible one. Which kind of
460
466
  // MFA you have is detected at sign-in time, so offering "approve a prompt"
461
467
  // and "type a code" as separate choices would be a distinction the code does
462
468
  // not make, and picking between them would change nothing on disk.
463
- console.log(" How do you complete MFA?");
464
- console.log(" 1. On your phone, or by typing a code here (recommended)");
465
- console.log(" 2. In a visible browser window");
469
+ console.log(" When your school asks you to approve the sign-in, how will you do it?");
470
+ console.log(" 1. I approve on my phone, or I type a code from an authenticator app (recommended)");
471
+ console.log(" 2. Something else — open a browser window I can use");
466
472
  let savedHeadless;
467
473
  try {
468
474
  savedHeadless = configStoreExists() ? loadConfigStore().headless : undefined;
@@ -495,23 +501,23 @@ async function main() {
495
501
  console.log(green(" Config saved to: " + getConfigStorePath()));
496
502
  console.log("");
497
503
  // ── Step 6: Authenticate now? ────────────────────────────────────
498
- const authNow = await ask(rl2, "Would you like to authenticate now? (yes/no): ");
504
+ const authNow = await ask(rl2, "Sign in now, so everything is ready to go? (yes/no): ");
499
505
  if (/^y(es)?$/i.test(authNow)) {
500
506
  rl2.close();
501
507
  console.log("");
502
- console.log(dim(" Starting authentication..."));
508
+ console.log(dim(" Signing you in..."));
503
509
  console.log("");
504
510
  const ok = await runAuth();
505
511
  rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
506
512
  if (ok) {
507
- console.log(green("\n Authentication successful!"));
513
+ console.log(green("\n Signed in!"));
508
514
  }
509
515
  else {
510
- console.log(yellow(`\n Authentication failed. You can retry later with: ${AUTH_COMMAND}`));
516
+ console.log(yellow(`\n Sign-in didn't finish. You can try again later by running: ${AUTH_COMMAND}`));
511
517
  }
512
518
  }
513
519
  else {
514
- console.log(dim(` You can authenticate later by running: ${AUTH_COMMAND}`));
520
+ console.log(dim(` You can sign in later by running: ${AUTH_COMMAND}`));
515
521
  }
516
522
  console.log("");
517
523
  // ── Step 7: Claude Desktop auto-config ───────────────────────────
@@ -590,14 +596,16 @@ async function main() {
590
596
  console.log(" Next steps:");
591
597
  if (configuredClients.length > 0) {
592
598
  console.log(` 1. Restart ${configuredClients.join(", ")}`);
593
- console.log(" 2. Ask your AI client about your Brightspace courses");
599
+ console.log(` 2. Ask it: "What's due this week?"`);
594
600
  console.log(" Sign-in runs automatically if your saved session has expired.");
595
601
  }
596
602
  else {
597
603
  console.log(" 1. Register the MCP server in your AI client using the command in README.md");
598
- console.log(" 2. Restart your AI client and ask about your Brightspace courses");
604
+ console.log(` 2. Restart your AI client, then ask it: "What's due this week?"`);
599
605
  }
600
606
  console.log("");
607
+ console.log(dim(` If something doesn't work, run: ${DOCTOR_COMMAND}`));
608
+ console.log("");
601
609
  }
602
610
  // Both entry points — the `brightspace-setup` bin and `brightspace-mcp-server
603
611
  // setup`, which imports this module — start the wizard here. VITEST is set
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import { ApiError } from "../api/index.js";
7
+ import { DownloadDropboxSubmissionFileSchema } from "./schemas.js";
8
+ import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
+ import { log } from "../utils/logger.js";
10
+ import { validateContentId, MAX_FILE_SIZE } from "../utils/file-validator.js";
11
+ import { secureDownload } from "../utils/download-helpers.js";
12
+ import path from "node:path";
13
+ import fs from "node:fs/promises";
14
+ /**
15
+ * Register download_dropbox_submission_file tool.
16
+ */
17
+ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
18
+ server.registerTool("download_dropbox_submission_file", {
19
+ title: "Download Dropbox Submission File",
20
+ description: "Download a specific file from a student's (or group's) dropbox submission to a local directory, " +
21
+ "for an instructor or TA. Use get_dropbox_submissions or get_dropbox_user_submissions first to " +
22
+ "find submissionId and fileId. Requires instructor or TA access to the course. " +
23
+ "IMPORTANT: Ask the user where they want to save the file before calling this tool. " +
24
+ "After identifying the file, suggest a clean readable filename and ask if they'd like to rename it.",
25
+ inputSchema: DownloadDropboxSubmissionFileSchema,
26
+ }, async (args) => {
27
+ try {
28
+ log("DEBUG", "download_dropbox_submission_file tool called", { args });
29
+ const { courseId, folderId, submissionId, fileId, downloadPath, customFilename } = DownloadDropboxSubmissionFileSchema.parse(args);
30
+ validateContentId(courseId);
31
+ validateContentId(folderId);
32
+ validateContentId(submissionId);
33
+ validateContentId(fileId);
34
+ if (!path.isAbsolute(downloadPath)) {
35
+ return errorResponse("downloadPath must be an absolute path (e.g., /Users/username/Downloads or C:\\Users\\username\\Downloads)");
36
+ }
37
+ try {
38
+ const stats = await fs.stat(downloadPath);
39
+ if (!stats.isDirectory()) {
40
+ return errorResponse(`downloadPath is not a directory: ${downloadPath}`);
41
+ }
42
+ }
43
+ catch (error) {
44
+ if (error?.code === "ENOENT") {
45
+ return errorResponse(`Download directory does not exist: ${downloadPath}`);
46
+ }
47
+ throw error;
48
+ }
49
+ // Fetch submission metadata to get the file list and validate fileId.
50
+ const submissionsPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/`);
51
+ let targetFile = null;
52
+ try {
53
+ const raw = await apiClient.get(submissionsPath);
54
+ const allSubmissions = Array.isArray(raw) ? raw : raw.Objects ?? [];
55
+ const submission = allSubmissions.find((s) => s.Id === submissionId);
56
+ if (!submission) {
57
+ return errorResponse(`Submission ${submissionId} not found in folder ${folderId}. ` +
58
+ "Use get_dropbox_submissions to list available submissions.");
59
+ }
60
+ targetFile = submission.Files.find((f) => f.FileId === fileId) ?? null;
61
+ if (!targetFile) {
62
+ const available = submission.Files.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ");
63
+ return errorResponse(`File ID ${fileId} not found in submission ${submissionId}. Available files: ${available}`);
64
+ }
65
+ }
66
+ catch (error) {
67
+ if (error instanceof ApiError && error.status === 403) {
68
+ return errorResponse("Instructor or TA access required for this course. This tool downloads another user's " +
69
+ "dropbox submission file, which Brightspace only grants to course staff.");
70
+ }
71
+ if (error instanceof ApiError && error.status === 404) {
72
+ return errorResponse(`Folder ${folderId} not found in course ${courseId}.`);
73
+ }
74
+ throw error;
75
+ }
76
+ // Check the size Brightspace reported before downloading anything.
77
+ if (targetFile.Size > MAX_FILE_SIZE) {
78
+ return errorResponse(`File too large (${Math.round(targetFile.Size / 1024 / 1024)}MB). ` +
79
+ `Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
80
+ }
81
+ const downloadApiPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/${submissionId}/files/${fileId}/download`);
82
+ let response;
83
+ try {
84
+ response = await apiClient.getRaw(downloadApiPath);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof ApiError && error.status === 403) {
88
+ return errorResponse("Instructor or TA access required for this course. Brightspace refused this download.");
89
+ }
90
+ if (error instanceof ApiError && error.status === 404) {
91
+ return errorResponse("File not found on the server. It may have been deleted.");
92
+ }
93
+ throw error;
94
+ }
95
+ // Check Content-Length before downloading the body (prevent memory exhaustion).
96
+ const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
97
+ if (contentLength > MAX_FILE_SIZE) {
98
+ return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). ` +
99
+ `Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
100
+ }
101
+ const buffer = Buffer.from(await response.arrayBuffer());
102
+ // Double-check the actual size once downloaded.
103
+ if (buffer.length > MAX_FILE_SIZE) {
104
+ return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). ` +
105
+ `Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
106
+ }
107
+ const originalFilename = targetFile.FileName;
108
+ const effectiveFilename = customFilename || originalFilename;
109
+ // secureDownload applies this project's path-containment and
110
+ // magic-byte file-type checks and resolves filename conflicts,
111
+ // rather than writing the fetched buffer straight to disk.
112
+ const result = await secureDownload({
113
+ targetDir: downloadPath,
114
+ filename: effectiveFilename,
115
+ data: buffer,
116
+ });
117
+ log("INFO", `download_dropbox_submission_file: saved ${result.path} (${result.size} bytes, ${result.mime})`);
118
+ return toolResponse({
119
+ success: true,
120
+ filePath: result.path,
121
+ fileSize: result.size,
122
+ mimeType: result.mime,
123
+ originalFilename,
124
+ message: `File downloaded successfully to ${result.path}`,
125
+ });
126
+ }
127
+ catch (error) {
128
+ return sanitizeError(error);
129
+ }
130
+ });
131
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import { ApiError } from "../api/index.js";
7
+ import { GetDropboxFeedbackSchema } from "./schemas.js";
8
+ import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
+ import { convertHtmlToMarkdown } from "../utils/html-converter.js";
10
+ import { log } from "../utils/logger.js";
11
+ /**
12
+ * Register get_dropbox_feedback tool.
13
+ */
14
+ export function registerGetDropboxFeedback(server, apiClient) {
15
+ server.registerTool("get_dropbox_feedback", {
16
+ title: "Get Dropbox Feedback",
17
+ description: "Retrieve the existing feedback already saved for a specific user or group in a dropbox folder, " +
18
+ "for an instructor or TA — score, graded state, feedback text, and rubric assessment detail. " +
19
+ "Use this before drafting new feedback to see what has already been recorded. Requires " +
20
+ "instructor or TA access to the course.",
21
+ inputSchema: GetDropboxFeedbackSchema,
22
+ }, async (args) => {
23
+ try {
24
+ log("DEBUG", "get_dropbox_feedback tool called", { args });
25
+ const { courseId, folderId, entityType, entityId } = GetDropboxFeedbackSchema.parse(args);
26
+ const feedbackPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/feedback/${entityType}/${entityId}`);
27
+ let feedback;
28
+ try {
29
+ feedback = await apiClient.get(feedbackPath);
30
+ }
31
+ catch (error) {
32
+ if (error instanceof ApiError && error.status === 403) {
33
+ return errorResponse("Instructor or TA access required for this course. This tool reads another user's " +
34
+ "grading feedback, which Brightspace only grants to course staff.");
35
+ }
36
+ if (error instanceof ApiError && error.status === 404) {
37
+ return toolResponse({
38
+ courseId,
39
+ folderId,
40
+ entityType,
41
+ entityId,
42
+ feedback: null,
43
+ note: "No feedback found. This submission has not been graded yet.",
44
+ });
45
+ }
46
+ throw error;
47
+ }
48
+ const feedbackMarkdown = feedback.Feedback?.Html
49
+ ? convertHtmlToMarkdown(feedback.Feedback.Html).markdown
50
+ : null;
51
+ const rubricAssessments = feedback.RubricAssessments?.map((ra) => ({
52
+ rubricId: ra.RubricId,
53
+ rubricName: ra.Name,
54
+ totalPoints: ra.TotalPoints,
55
+ // Adapted from get-rubrics-for-object.ts's defensive ?? [] — some
56
+ // tenants return a null Criteria array on an unscored assessment.
57
+ criteria: (ra.Criteria ?? []).map((c) => ({
58
+ criterionId: c.CriterionId,
59
+ selectedLevelId: c.LevelId,
60
+ points: c.Points,
61
+ comments: c.Comments?.Text ?? null,
62
+ })),
63
+ })) ?? [];
64
+ log("INFO", `get_dropbox_feedback: retrieved feedback for ${entityType} ${entityId} in folder ${folderId}`);
65
+ return toolResponse({
66
+ courseId,
67
+ folderId,
68
+ entityType,
69
+ entityId,
70
+ feedback: {
71
+ score: feedback.Score,
72
+ isGraded: feedback.IsGraded,
73
+ feedbackText: feedback.Feedback?.Text ?? null,
74
+ feedbackMarkdown,
75
+ rubricAssessments,
76
+ },
77
+ });
78
+ }
79
+ catch (error) {
80
+ return sanitizeError(error);
81
+ }
82
+ });
83
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { GetDropboxFoldersSchema } from "./schemas.js";
8
+ import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
+ import { log } from "../utils/logger.js";
10
+ /** Map SubmissionType integer to a readable label. */
11
+ function submissionTypeLabel(t) {
12
+ switch (t) {
13
+ case 0: return "file";
14
+ case 1: return "text";
15
+ case 2: return "on_paper";
16
+ case 3: return "observed_in_person";
17
+ default: return "unknown";
18
+ }
19
+ }
20
+ /**
21
+ * Register get_dropbox_folders tool.
22
+ */
23
+ export function registerGetDropboxFolders(server, apiClient) {
24
+ server.registerTool("get_dropbox_folders", {
25
+ title: "Get Dropbox Folders",
26
+ description: "List all assignment/dropbox folders for a course, for an instructor or TA. Returns folder ID, " +
27
+ "name, due date, start/end dates, submission type, visibility, and whether rubrics are attached. " +
28
+ "Use this to discover folderId values before calling get_dropbox_submissions, " +
29
+ "get_dropbox_user_submissions, get_dropbox_feedback, or get_rubrics_for_object. " +
30
+ "This reads the same folder listing a student's own client uses, so it is not gated to " +
31
+ "instructor/TA accounts the way get_dropbox_submissions, get_dropbox_feedback, and " +
32
+ "download_dropbox_submission_file are.",
33
+ inputSchema: GetDropboxFoldersSchema,
34
+ }, async (args) => {
35
+ try {
36
+ log("DEBUG", "get_dropbox_folders tool called", { args });
37
+ const { courseId } = GetDropboxFoldersSchema.parse(args);
38
+ const apiPath = apiClient.le(courseId, "/dropbox/folders/");
39
+ let rawFolders;
40
+ try {
41
+ const response = await apiClient.get(apiPath, { ttl: DEFAULT_CACHE_TTLS.assignments });
42
+ rawFolders = Array.isArray(response) ? response : response.Objects ?? [];
43
+ }
44
+ catch (error) {
45
+ if (error instanceof ApiError && error.status === 403) {
46
+ return errorResponse("Instructor or TA access required for this course. This tool reads every student's " +
47
+ "dropbox folder metadata, which Brightspace only grants to course staff.");
48
+ }
49
+ if (error instanceof ApiError && error.status === 404) {
50
+ return toolResponse({
51
+ courseId,
52
+ folders: [],
53
+ note: "No dropbox folders found for this course.",
54
+ });
55
+ }
56
+ throw error;
57
+ }
58
+ const folders = rawFolders.map((f) => ({
59
+ folderId: f.Id,
60
+ name: f.Name,
61
+ categoryId: f.CategoryId,
62
+ isHidden: f.IsHidden,
63
+ isGroup: f.GroupTypeId !== null,
64
+ submissionType: submissionTypeLabel(f.SubmissionType),
65
+ dueDate: f.DueDate ?? null,
66
+ startDate: f.StartDate ?? null,
67
+ endDate: f.EndDate ?? null,
68
+ maxScore: f.Assessment?.ScoreDenominator ?? null,
69
+ rubrics: f.Assessment?.Rubrics?.map((r) => ({
70
+ rubricId: r.RubricId,
71
+ name: r.Name,
72
+ })) ?? [],
73
+ hasRubrics: (f.Assessment?.Rubrics?.length ?? 0) > 0,
74
+ }));
75
+ log("INFO", `get_dropbox_folders: ${folders.length} folders for course ${courseId}`);
76
+ return toolResponse({
77
+ courseId,
78
+ folders,
79
+ ...(folders.length === 0 ? { note: "No dropbox folders found for this course." } : {}),
80
+ });
81
+ }
82
+ catch (error) {
83
+ return sanitizeError(error);
84
+ }
85
+ });
86
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { GetDropboxSubmissionsSchema } from "./schemas.js";
8
+ import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
+ import { log } from "../utils/logger.js";
10
+ // Per-submitter feedback lookup is one GET each; cap how many run at once so
11
+ // a large dropbox folder can't fan out hundreds of concurrent requests.
12
+ const FEEDBACK_FETCH_CONCURRENCY = 8;
13
+ /** Run `fn` over `items`, at most `concurrency` in flight at a time. */
14
+ async function mapWithConcurrency(items, concurrency, fn) {
15
+ let next = 0;
16
+ async function worker() {
17
+ while (next < items.length) {
18
+ const item = items[next++];
19
+ await fn(item);
20
+ }
21
+ }
22
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
23
+ }
24
+ /**
25
+ * Register get_dropbox_submissions tool.
26
+ */
27
+ export function registerGetDropboxSubmissions(server, apiClient) {
28
+ server.registerTool("get_dropbox_submissions", {
29
+ title: "Get Dropbox Submissions",
30
+ description: "List every student's (or group's) submission to a dropbox folder, for an instructor or TA — " +
31
+ "submitter names, submission dates, late status, file lists, and feedback/grading status. " +
32
+ "Use get_dropbox_folders first to find folderId. Requires instructor or TA access; a student " +
33
+ "account gets a clear note instead of data (a student's own download_file / get_assignments " +
34
+ "already cover their own submission). Results are capped by limit (default 100, max 1000); " +
35
+ "a truncated response says so and only fetches feedback for the returned slice.",
36
+ inputSchema: GetDropboxSubmissionsSchema,
37
+ }, async (args) => {
38
+ try {
39
+ log("DEBUG", "get_dropbox_submissions tool called", { args });
40
+ const { courseId, folderId, activeOnly, ignoreFeedback, limit } = GetDropboxSubmissionsSchema.parse(args);
41
+ const folderPath = apiClient.le(courseId, "/dropbox/folders/");
42
+ const submissionsPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/`);
43
+ let rawSubmissions;
44
+ let folder = null;
45
+ try {
46
+ const [foldersRaw, submissionsRaw] = await Promise.all([
47
+ apiClient.get(folderPath, { ttl: DEFAULT_CACHE_TTLS.assignments }),
48
+ apiClient.get(submissionsPath),
49
+ ]);
50
+ const allFolders = Array.isArray(foldersRaw) ? foldersRaw : foldersRaw.Objects ?? [];
51
+ folder = allFolders.find((f) => f.Id === folderId) ?? null;
52
+ rawSubmissions = Array.isArray(submissionsRaw) ? submissionsRaw : submissionsRaw.Objects ?? [];
53
+ }
54
+ catch (error) {
55
+ if (error instanceof ApiError && error.status === 403) {
56
+ return errorResponse("Instructor or TA access required for this course. This tool reads every student's " +
57
+ "dropbox submissions, which Brightspace only grants to course staff.");
58
+ }
59
+ if (error instanceof ApiError && error.status === 404) {
60
+ return toolResponse({
61
+ courseId,
62
+ folderId,
63
+ folderName: null,
64
+ dueDate: null,
65
+ submissions: [],
66
+ note: `Dropbox folder ${folderId} not found in course ${courseId}.`,
67
+ });
68
+ }
69
+ throw error;
70
+ }
71
+ if (rawSubmissions.length === 0) {
72
+ return toolResponse({
73
+ courseId,
74
+ folderId,
75
+ folderName: folder?.Name ?? null,
76
+ dueDate: folder?.DueDate ?? null,
77
+ submissions: [],
78
+ note: "No submissions found for this dropbox folder.",
79
+ });
80
+ }
81
+ // Cap the fan-out up front: a large dropbox folder could otherwise
82
+ // mean hundreds of concurrent per-submitter feedback GETs and an
83
+ // unbounded payload. Only the kept slice gets a feedback lookup.
84
+ const total = rawSubmissions.length;
85
+ const truncated = total > limit;
86
+ const kept = truncated ? rawSubmissions.slice(0, limit) : rawSubmissions;
87
+ // Optionally fetch feedback for each kept submitter, a bounded
88
+ // number at a time. Best-effort: a submitter with no feedback yet
89
+ // simply has none in the map.
90
+ const feedbackMap = new Map();
91
+ if (!ignoreFeedback) {
92
+ await mapWithConcurrency(kept, FEEDBACK_FETCH_CONCURRENCY, async (sub) => {
93
+ try {
94
+ const userId = sub.SubmittedBy.Identifier;
95
+ const feedbackPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/feedback/user/${userId}`);
96
+ const fb = await apiClient.get(feedbackPath);
97
+ feedbackMap.set(userId, fb);
98
+ }
99
+ catch {
100
+ // No feedback yet, or this submitter's feedback route
101
+ // otherwise failed — silently skip, matching the "not
102
+ // graded yet" default below.
103
+ }
104
+ });
105
+ }
106
+ const dueDate = folder?.DueDate ? new Date(folder.DueDate) : null;
107
+ const maxScore = folder?.Assessment?.ScoreDenominator ?? null;
108
+ const submissions = kept.map((sub) => {
109
+ const userId = sub.SubmittedBy.Identifier;
110
+ const submittedAt = new Date(sub.SubmissionDate);
111
+ const isLate = dueDate !== null && submittedAt > dueDate;
112
+ const feedback = feedbackMap.get(userId) ?? null;
113
+ const entry = {
114
+ submissionId: sub.Id,
115
+ userId,
116
+ displayName: sub.SubmittedBy.DisplayName,
117
+ submittedDate: sub.SubmissionDate,
118
+ isLate,
119
+ files: sub.Files.map((f) => ({
120
+ fileId: f.FileId,
121
+ name: f.FileName,
122
+ size: f.Size,
123
+ })),
124
+ comment: sub.Comment?.Text ?? null,
125
+ };
126
+ if (!ignoreFeedback) {
127
+ entry.feedbackStatus = feedback
128
+ ? {
129
+ isGraded: feedback.IsGraded,
130
+ score: feedback.Score,
131
+ maxScore,
132
+ hasFeedbackText: !!feedback.Feedback?.Text,
133
+ }
134
+ : { isGraded: false, score: null, maxScore, hasFeedbackText: false };
135
+ }
136
+ return entry;
137
+ });
138
+ // activeOnly: skip submissions that are already fully graded.
139
+ const filtered = activeOnly && !ignoreFeedback
140
+ ? submissions.filter((s) => !s.feedbackStatus?.isGraded)
141
+ : submissions;
142
+ log("INFO", `get_dropbox_submissions: ${filtered.length} submissions (of ${total} total) for folder ${folderId}`, { truncated });
143
+ return toolResponse({
144
+ courseId,
145
+ folderId,
146
+ folderName: folder?.Name ?? null,
147
+ dueDate: folder?.DueDate ?? null,
148
+ maxScore,
149
+ totalSubmissions: total,
150
+ returnedSubmissions: filtered.length,
151
+ truncated,
152
+ ...(truncated
153
+ ? { note: `Showing ${kept.length} of ${total} submissions. Raise the limit argument to see more.` }
154
+ : {}),
155
+ activeOnly,
156
+ submissions: filtered,
157
+ });
158
+ }
159
+ catch (error) {
160
+ return sanitizeError(error);
161
+ }
162
+ });
163
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import { ApiError } from "../api/index.js";
7
+ import { GetDropboxUserSubmissionsSchema } from "./schemas.js";
8
+ import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
+ import { log } from "../utils/logger.js";
10
+ /**
11
+ * Register get_dropbox_user_submissions tool.
12
+ */
13
+ export function registerGetDropboxUserSubmissions(server, apiClient) {
14
+ server.registerTool("get_dropbox_user_submissions", {
15
+ title: "Get Dropbox User Submissions",
16
+ description: "Retrieve all submissions made by one specific student (or group) in a dropbox folder, for an " +
17
+ "instructor or TA — useful for reviewing a single student's work before drafting feedback. " +
18
+ "Use get_dropbox_submissions or get_roster to find the userId first. Requires instructor or TA " +
19
+ "access to the course.",
20
+ inputSchema: GetDropboxUserSubmissionsSchema,
21
+ }, async (args) => {
22
+ try {
23
+ log("DEBUG", "get_dropbox_user_submissions tool called", { args });
24
+ const { courseId, folderId, userId, ignoreFeedback } = GetDropboxUserSubmissionsSchema.parse(args);
25
+ const submissionsPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/users/${userId}`);
26
+ let rawSubmissions;
27
+ try {
28
+ const raw = await apiClient.get(submissionsPath);
29
+ rawSubmissions = Array.isArray(raw) ? raw : raw.Objects ?? [];
30
+ }
31
+ catch (error) {
32
+ if (error instanceof ApiError && error.status === 403) {
33
+ return errorResponse("Instructor or TA access required for this course. This tool reads another user's " +
34
+ "dropbox submissions, which Brightspace only grants to course staff.");
35
+ }
36
+ if (error instanceof ApiError && error.status === 404) {
37
+ return toolResponse({
38
+ courseId,
39
+ folderId,
40
+ userId,
41
+ submissions: [],
42
+ note: `No submissions found for user ${userId} in dropbox folder ${folderId}. ` +
43
+ "The user may not have submitted, or the folder may not exist.",
44
+ });
45
+ }
46
+ throw error;
47
+ }
48
+ if (rawSubmissions.length === 0) {
49
+ return toolResponse({
50
+ courseId,
51
+ folderId,
52
+ userId,
53
+ submissions: [],
54
+ note: "No submissions found for this user in the specified folder.",
55
+ });
56
+ }
57
+ let feedback = null;
58
+ if (!ignoreFeedback) {
59
+ try {
60
+ const feedbackPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/feedback/user/${userId}`);
61
+ feedback = await apiClient.get(feedbackPath);
62
+ }
63
+ catch {
64
+ // No feedback yet — silently skip, matching the "not graded yet" default below.
65
+ }
66
+ }
67
+ const submissions = rawSubmissions.map((sub) => ({
68
+ submissionId: sub.Id,
69
+ userId: sub.SubmittedBy.Identifier,
70
+ displayName: sub.SubmittedBy.DisplayName,
71
+ submittedDate: sub.SubmissionDate,
72
+ files: sub.Files.map((f) => ({
73
+ fileId: f.FileId,
74
+ name: f.FileName,
75
+ size: f.Size,
76
+ })),
77
+ comment: sub.Comment?.Text ?? null,
78
+ }));
79
+ const feedbackStatus = ignoreFeedback
80
+ ? undefined
81
+ : feedback
82
+ ? {
83
+ isGraded: feedback.IsGraded,
84
+ score: feedback.Score,
85
+ hasFeedbackText: !!feedback.Feedback?.Text,
86
+ feedbackText: feedback.Feedback?.Text ?? null,
87
+ }
88
+ : { isGraded: false, score: null, hasFeedbackText: false, feedbackText: null };
89
+ log("INFO", `get_dropbox_user_submissions: ${submissions.length} submissions for user ${userId} in folder ${folderId}`);
90
+ return toolResponse({
91
+ courseId,
92
+ folderId,
93
+ userId,
94
+ submissions,
95
+ feedbackStatus,
96
+ });
97
+ }
98
+ catch (error) {
99
+ return sanitizeError(error);
100
+ }
101
+ });
102
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { GetRubricsForObjectSchema } from "./schemas.js";
8
+ import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
+ import { log } from "../utils/logger.js";
10
+ /**
11
+ * Register get_rubrics_for_object tool.
12
+ */
13
+ export function registerGetRubricsForObject(server, apiClient) {
14
+ server.registerTool("get_rubrics_for_object", {
15
+ title: "Get Rubrics for Dropbox Folder",
16
+ description: "Retrieve the full rubric table (criteria, levels, and point values) attached to a dropbox " +
17
+ "folder, for an instructor or TA. Use get_dropbox_folders first to find folderId. Like " +
18
+ "get_dropbox_folders, this reads the folder listing's embedded rubric data, so it is not " +
19
+ "gated to instructor/TA accounts the way get_dropbox_submissions and get_dropbox_feedback are.",
20
+ inputSchema: GetRubricsForObjectSchema,
21
+ }, async (args) => {
22
+ try {
23
+ log("DEBUG", "get_rubrics_for_object tool called", { args });
24
+ const { courseId, folderId } = GetRubricsForObjectSchema.parse(args);
25
+ const apiPath = apiClient.le(courseId, "/dropbox/folders/");
26
+ let rawFolders;
27
+ try {
28
+ const response = await apiClient.get(apiPath, { ttl: DEFAULT_CACHE_TTLS.assignments });
29
+ rawFolders = Array.isArray(response) ? response : response.Objects ?? [];
30
+ }
31
+ catch (error) {
32
+ if (error instanceof ApiError && error.status === 403) {
33
+ return errorResponse("Instructor or TA access required for this course. This tool reads dropbox rubric " +
34
+ "metadata, which Brightspace only grants to course staff.");
35
+ }
36
+ if (error instanceof ApiError && error.status === 404) {
37
+ return toolResponse({
38
+ courseId,
39
+ folderId,
40
+ folderName: null,
41
+ rubrics: [],
42
+ note: `Course ${courseId} was not found.`,
43
+ });
44
+ }
45
+ throw error;
46
+ }
47
+ const folder = rawFolders.find((f) => f.Id === folderId);
48
+ if (!folder) {
49
+ return toolResponse({
50
+ courseId,
51
+ folderId,
52
+ folderName: null,
53
+ rubrics: [],
54
+ note: `Dropbox folder ${folderId} not found in course ${courseId}.`,
55
+ });
56
+ }
57
+ const rubrics = folder.Assessment?.Rubrics;
58
+ if (!rubrics || rubrics.length === 0) {
59
+ return toolResponse({
60
+ courseId,
61
+ folderId,
62
+ folderName: folder.Name,
63
+ rubrics: [],
64
+ note: "No rubrics are attached to this dropbox folder.",
65
+ });
66
+ }
67
+ const formattedRubrics = rubrics.map((r) => ({
68
+ rubricId: r.RubricId,
69
+ name: r.Name,
70
+ criteria: (r.Criteria ?? []).map((c) => ({
71
+ criterionId: c.CriterionId,
72
+ name: c.Name,
73
+ levels: (c.Levels ?? []).map((l) => ({
74
+ levelId: l.LevelId,
75
+ name: l.Name,
76
+ points: l.Points,
77
+ description: l.Description?.Text ?? null,
78
+ })),
79
+ })),
80
+ }));
81
+ log("INFO", `get_rubrics_for_object: ${formattedRubrics.length} rubric(s) for folder ${folderId}`);
82
+ return toolResponse({
83
+ courseId,
84
+ folderId,
85
+ folderName: folder.Name,
86
+ maxScore: folder.Assessment?.ScoreDenominator ?? null,
87
+ rubrics: formattedRubrics,
88
+ });
89
+ }
90
+ catch (error) {
91
+ return sanitizeError(error);
92
+ }
93
+ });
94
+ }
@@ -23,6 +23,13 @@ export { registerGetDiscussions } from "./get-discussions.js";
23
23
  export { registerGetVideoTranscript } from "./get-video-transcript.js";
24
24
  export { registerGetServerInfo } from "./get-server-info.js";
25
25
  export { registerSearchCourse } from "./search-course.js";
26
+ // Instructor / TA dropbox tools (read-only)
27
+ export { registerGetDropboxFolders } from "./get-dropbox-folders.js";
28
+ export { registerGetDropboxSubmissions } from "./get-dropbox-submissions.js";
29
+ export { registerGetDropboxUserSubmissions } from "./get-dropbox-user-submissions.js";
30
+ export { registerGetDropboxFeedback } from "./get-dropbox-feedback.js";
31
+ export { registerGetRubricsForObject } from "./get-rubrics-for-object.js";
32
+ export { registerDownloadDropboxSubmissionFile } from "./download-dropbox-submission-file.js";
26
33
  // Re-export shared helpers and schemas for convenience
27
34
  export { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
28
35
  export * from "./schemas.js";
@@ -156,3 +156,64 @@ export const GetMyGroupsSchema = z.object({
156
156
  .describe("Course ID to list the current user's project/discussion groups for."),
157
157
  });
158
158
  export const GetServerInfoSchema = z.object({});
159
+ // ── Instructor / TA dropbox tools ──────────────────────────────────────────
160
+ // Read-only, instructor/TA-side views of a course's dropbox (assignment)
161
+ // folders and the submissions inside them. These answer "who has/hasn't
162
+ // submitted" and "what feedback has already been given", distinct from the
163
+ // student-facing tools above which only ever see the caller's own work.
164
+ export const GetDropboxFoldersSchema = z.object({
165
+ courseId: z.coerce.number().int().positive()
166
+ .describe("Course ID to list dropbox (assignment) folders for."),
167
+ });
168
+ export const GetDropboxSubmissionsSchema = z.object({
169
+ courseId: z.coerce.number().int().positive()
170
+ .describe("Course ID the dropbox folder belongs to."),
171
+ folderId: z.coerce.number().int().positive()
172
+ .describe("Dropbox folder ID to list submissions for. Use get_dropbox_folders to find it."),
173
+ activeOnly: z.boolean().default(false)
174
+ .describe("When true, skip submissions that have already been fully graded (feedback published with a score). Default false: returns every submission, graded and ungraded."),
175
+ ignoreFeedback: z.boolean().default(false)
176
+ .describe("When true, omit feedback status from each submission entry to reduce response size."),
177
+ limit: z.coerce.number().int().positive().max(1000).default(100)
178
+ .describe("Maximum submissions to return. Default 100. The response reports the true total and whether it was truncated; feedback is only fetched for the returned slice."),
179
+ });
180
+ export const GetDropboxUserSubmissionsSchema = z.object({
181
+ courseId: z.coerce.number().int().positive()
182
+ .describe("Course ID the dropbox folder belongs to."),
183
+ folderId: z.coerce.number().int().positive()
184
+ .describe("Dropbox folder ID."),
185
+ userId: z.coerce.number().int().positive()
186
+ .describe("Brightspace user ID (or group ID for a group folder) whose submissions to retrieve."),
187
+ ignoreFeedback: z.boolean().default(false)
188
+ .describe("When true, omit feedback status from the response."),
189
+ });
190
+ export const GetDropboxFeedbackSchema = z.object({
191
+ courseId: z.coerce.number().int().positive()
192
+ .describe("Course ID the dropbox folder belongs to."),
193
+ folderId: z.coerce.number().int().positive()
194
+ .describe("Dropbox folder ID."),
195
+ entityType: z.enum(["user", "group"])
196
+ .describe("Whether entityId identifies an individual user or a group."),
197
+ entityId: z.coerce.number().int().positive()
198
+ .describe("User ID or group ID whose existing feedback to retrieve."),
199
+ });
200
+ export const GetRubricsForObjectSchema = z.object({
201
+ courseId: z.coerce.number().int().positive()
202
+ .describe("Course ID the dropbox folder belongs to."),
203
+ folderId: z.coerce.number().int().positive()
204
+ .describe("Dropbox folder ID whose rubric(s) to retrieve full criteria/level detail for."),
205
+ });
206
+ export const DownloadDropboxSubmissionFileSchema = z.object({
207
+ courseId: z.coerce.number().int().positive()
208
+ .describe("Course ID the dropbox folder belongs to."),
209
+ folderId: z.coerce.number().int().positive()
210
+ .describe("Dropbox folder ID."),
211
+ submissionId: z.coerce.number().int().positive()
212
+ .describe("Submission ID, from get_dropbox_submissions or get_dropbox_user_submissions."),
213
+ fileId: z.coerce.number().int().positive()
214
+ .describe("File ID within that submission."),
215
+ downloadPath: z.string().min(1)
216
+ .describe("Absolute path to the local directory where the file should be saved."),
217
+ customFilename: z.string().max(255).optional()
218
+ .describe("Custom filename for the downloaded file (include extension). If omitted, uses the original submission filename."),
219
+ });
@@ -19,6 +19,8 @@ export const PACKAGE_NAME = "brightspace-mcp-server";
19
19
  export const AUTH_COMMAND = `npx -y ${PACKAGE_NAME}@latest auth`;
20
20
  /** Re-run the setup wizard. Always runs the current published release. */
21
21
  export const SETUP_COMMAND = `npx -y ${PACKAGE_NAME}@latest setup`;
22
+ /** Diagnose a broken setup. Always runs the current published release. */
23
+ export const DOCTOR_COMMAND = `npx -y ${PACKAGE_NAME}@latest doctor`;
22
24
  /** Bring a global install up to date. The only fix for a stale `npm i -g` copy. */
23
25
  export const GLOBAL_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}@latest`;
24
26
  /** Drop cached npx copies so the next `@latest` genuinely refetches. */
@@ -211,7 +211,8 @@ export function parseSessionCookieEnv(raw) {
211
211
  }
212
212
  throw new Error(SESSION_COOKIE_FORMAT_ERROR);
213
213
  }
214
- function expandTilde(filePath) {
214
+ /** Exported for the `doctor` CLI, which resolves the same session directory without loading the full app config. */
215
+ export function expandTilde(filePath) {
215
216
  if (filePath.startsWith("~")) {
216
217
  return path.join(os.homedir(), filePath.slice(1));
217
218
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.9.16",
3
+ "version": "3.9.18",
4
4
  "mcpName": "io.github.rohanmuppa/brightspace",
5
5
  "description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
6
6
  "type": "module",