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 +2 -0
- package/build/doctor.js +331 -0
- package/build/index.js +13 -3
- package/build/setup.js +24 -16
- package/build/tools/download-dropbox-submission-file.js +131 -0
- package/build/tools/get-dropbox-feedback.js +83 -0
- package/build/tools/get-dropbox-folders.js +86 -0
- package/build/tools/get-dropbox-submissions.js +163 -0
- package/build/tools/get-dropbox-user-submissions.js +102 -0
- package/build/tools/get-rubrics-for-object.js +94 -0
- package/build/tools/index.js +7 -0
- package/build/tools/schemas.js +61 -0
- package/build/utils/commands.js +2 -0
- package/build/utils/config.js +2 -1
- package/package.json +1 -1
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.
|
package/build/doctor.js
ADDED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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(`
|
|
459
|
+
console.log(dim(` ${preset.mfaNote}`));
|
|
454
460
|
}
|
|
455
461
|
else {
|
|
456
|
-
console.log(dim("
|
|
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("
|
|
464
|
-
console.log(" 1.
|
|
465
|
-
console.log(" 2.
|
|
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, "
|
|
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("
|
|
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
|
|
513
|
+
console.log(green("\n Signed in!"));
|
|
508
514
|
}
|
|
509
515
|
else {
|
|
510
|
-
console.log(yellow(`\n
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/build/tools/index.js
CHANGED
|
@@ -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";
|
package/build/tools/schemas.js
CHANGED
|
@@ -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
|
+
});
|
package/build/utils/commands.js
CHANGED
|
@@ -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. */
|
package/build/utils/config.js
CHANGED
|
@@ -211,7 +211,8 @@ export function parseSessionCookieEnv(raw) {
|
|
|
211
211
|
}
|
|
212
212
|
throw new Error(SESSION_COOKIE_FORMAT_ERROR);
|
|
213
213
|
}
|
|
214
|
-
|
|
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.
|
|
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",
|