brightspace-mcp-server 3.9.15 → 3.9.17

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
@@ -28,14 +28,14 @@ Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and a
28
28
  It asks for your school's Brightspace address, your username and password, and how you normally do two-factor sign-in (phone approval, authenticator code, or a browser window). Your password goes into your computer's own password store (Keychain on Mac, Credential Manager on Windows), never into a file. At the end it connects itself to Claude Desktop, Cursor, Codex, or Claude Code if you have them.
29
29
  3. **Restart your AI app and ask it something** — the first question signs you in. If your phone asks you to approve a sign-in, approve it and ask again.
30
30
 
31
- At one of these schools, add the flag and skip typing the address: `--purdue`, `--suny`, `--western`, `--tudelft`, `--cuny`, `--leiden`.
31
+ At one of these schools, add the flag and skip typing the address: `--purdue`, `--suny`, `--western`, `--tudelft`, `--cuny`, `--leiden`, `--javeriana`.
32
32
 
33
33
  **Rather have the AI install it for you?** Paste this into Claude Code, Cursor, Windsurf, Copilot, or Codex:
34
34
 
35
35
  ```
36
36
  Install brightspace-mcp-server for me by following
37
37
  https://github.com/RohanMuppa/brightspace-mcp-server/blob/main/LLMs.md
38
- (use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, --cuny at CUNY, or --leiden at Leiden).
38
+ (use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, --cuny at CUNY, --leiden at Leiden, or --javeriana at Javeriana Cali).
39
39
  ```
40
40
 
41
41
  Using a different AI app? Add the command `npx -y brightspace-mcp-server@latest` to its MCP settings (on Windows: `cmd /c npx -y brightspace-mcp-server@latest`). Exact steps for each app: [docs/troubleshooting.md](docs/troubleshooting.md).
@@ -52,6 +52,7 @@ If your school uses D2L Brightspace, yes. These have automatic sign-in built in:
52
52
  | TU Delft | NetID (no two-factor) | `--tudelft` |
53
53
  | CUNY | CUNY Login, authenticator code each time | `--cuny` |
54
54
  | Leiden University | SURFconext → Microsoft | `--leiden` |
55
+ | Javeriana Cali | OneGate (password + authenticator code) | `--javeriana` |
55
56
  | Any other D2L school | Paste your Brightspace address; if the login page isn't recognized, a browser window opens so you can sign in by hand | none |
56
57
 
57
58
  School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
@@ -70,6 +71,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
70
71
  | Discussions | "Summarize the latest posts in the final project thread" |
71
72
  | Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura and YouTube) |
72
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) |
73
75
 
74
76
  ## Prompts
75
77
 
@@ -9,6 +9,7 @@ export { SunySSOFlow, isSunyBrightspace } from "./suny-sso.js";
9
9
  export { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
10
10
  export { CunySSOFlow, isCunyBrightspace } from "./cuny-sso.js";
11
11
  export { LeidenSSOFlow, isLeidenBrightspace } from "./leiden-sso.js";
12
+ export { JaverianaSSOFlow, isJaverianaBrightspace } from "./javeriana-sso.js";
12
13
  export { createSSOFlow } from "./sso-flow.js";
13
14
  export { TokenManager } from "./token-manager.js";
14
15
  export { SessionStore } from "./session-store.js";
@@ -0,0 +1,322 @@
1
+ /** Pontificia Universidad Javeriana Cali login via MobilityGuard OneGate. Licensed under MIT; see LICENSE. */
2
+ import { BrowserAuthError } from "../utils/errors.js";
3
+ import { log } from "../utils/logger.js";
4
+ import { AUTH_COMMAND, SETUP_COMMAND } from "../utils/commands.js";
5
+ import { MfaApprovalError, UnsupportedAuthenticationError } from "./sso-flow.js";
6
+ /**
7
+ * Ported from Joshua Mendez (@JoshuaMontclair)'s fork, branch
8
+ * feat/javeriana-cali-sso (MIT). The fork's stored-TOTP-secret path and its
9
+ * separate sso-factory.ts are not carried over: here, as with every other
10
+ * code-based MFA in this project, the authenticator code is typed by a
11
+ * person through the terminal prompt (see cuny-sso.ts, which this flow is
12
+ * otherwise modeled on).
13
+ *
14
+ * Brightspace federates by SAML to MobilityGuard OneGate at
15
+ * mg-local.servicios.javerianacali.edu.co:
16
+ *
17
+ * /d2l/login → ... → IdP "Elija su método de autenticación" chooser
18
+ * → GET /mg-local/login?type=webtoken ("Usuario y Contraseña")
19
+ * → (submit #form) → an /auth/totp-pattern page asking for the code
20
+ * → (submit #otp-form) → /d2l/home
21
+ *
22
+ * Both the credentials form (#form) and the code form (#otp-form) copy their
23
+ * typed values into hidden inputs from a submit handler wired up on `load`;
24
+ * clicking before that handler attaches posts the hidden fields empty. Hence
25
+ * `waitForLoadState("load")` before every click. The code field is rendered
26
+ * visible but disabled at first paint, so being editable — not merely
27
+ * visible — is the only honest sign that OneGate is ready for it.
28
+ */
29
+ const JAVERIANA_BRIGHTSPACE_HOST = "auladigital.javerianacali.edu.co";
30
+ const ONEGATE_HOST = "mg-local.servicios.javerianacali.edu.co";
31
+ /** "Usuario y Contraseña" — OneGate's chooser link for password sign-in. */
32
+ const CREDENTIALS_FORM_PATH = "/mg-local/login?type=webtoken";
33
+ const SELECTORS = {
34
+ username: "input#user-id",
35
+ password: "input#password",
36
+ token: "input#token",
37
+ credentialsSubmit: 'form#form input[type="submit"]',
38
+ tokenSubmit: 'form#otp-form input[type="submit"]',
39
+ /** OneGate's status banner. Rendered hidden and unhidden to show errors. */
40
+ statusMessage: ".box--system-message",
41
+ /**
42
+ * The method chooser's own "Usuario y Contraseña" link — a positive
43
+ * marker that distinguishes the chooser from a transient interstitial
44
+ * (a SAML auto-post, a "processing" page) that also lacks the
45
+ * username/token fields but must not be navigated away from.
46
+ */
47
+ chooserLink: 'a[href*="type=webtoken"]',
48
+ };
49
+ const POLL_MS = 500;
50
+ /** OneGate parks on the chooser and credentials form briefly before the code challenge. */
51
+ const CHALLENGE_TIMEOUT_MS = 60_000;
52
+ /** A person has to find their authenticator and read a code. */
53
+ const MFA_TIMEOUT_MS = 5 * 60 * 1000;
54
+ const FORM_NAVIGATION_TIMEOUT_MS = 30_000;
55
+ /** How many mistyped codes to re-prompt for before giving up on this challenge. */
56
+ const MAX_CODE_ATTEMPTS = 3;
57
+ /** True for Javeriana Cali's Brightspace tenant. */
58
+ export function isJaverianaBrightspace(baseUrl) {
59
+ try {
60
+ return new URL(baseUrl).hostname.toLowerCase() === JAVERIANA_BRIGHTSPACE_HOST;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ /** Compare hosts, not substrings: a lookalike domain must never pass this check. */
67
+ function isOneGate(url) {
68
+ try {
69
+ const parsed = new URL(url);
70
+ return parsed.protocol === "https:" && parsed.hostname.toLowerCase() === ONEGATE_HOST;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /**
77
+ * Login flow for Javeriana Cali's Brightspace, behind MobilityGuard OneGate.
78
+ *
79
+ * OneGate's chooser is a plain GET link rather than a visible form, so
80
+ * `selectIdentityProvider` follows it during the shared silent-SSO poll
81
+ * (see browser-auth.ts's clickSilentSurfaces) before a human ever has to;
82
+ * `login` calls the same step again in case nothing resolved it yet. The
83
+ * authenticator code OneGate asks for afterward has no stored secret to
84
+ * generate it from, so it comes from a person, through the same terminal
85
+ * prompt or visible browser every other code-based MFA in this project uses.
86
+ */
87
+ export class JaverianaSSOFlow {
88
+ config;
89
+ /** Guards the chooser-to-credentials-form redirect to at most once per login. */
90
+ credentialsFormOpened = false;
91
+ constructor(config) {
92
+ this.config = config;
93
+ }
94
+ hasCredentials() {
95
+ return Boolean(this.config.username && this.config.password);
96
+ }
97
+ /** Follow OneGate's "Usuario y Contraseña" chooser link before a human has to. */
98
+ async selectIdentityProvider(page) {
99
+ if (!isOneGate(page.url()))
100
+ return;
101
+ await this.openCredentialsForm(page);
102
+ }
103
+ async login(page) {
104
+ try {
105
+ log("INFO", "Starting Javeriana Cali sign-in flow (MobilityGuard OneGate)");
106
+ await this.completeLogin(page);
107
+ return true;
108
+ }
109
+ catch (error) {
110
+ if (error instanceof BrowserAuthError)
111
+ throw error;
112
+ throw new UnsupportedAuthenticationError("OneGate could not complete automatic sign-in. Check saved credentials and your authenticator settings.", error);
113
+ }
114
+ }
115
+ /**
116
+ * Poll the page and answer whichever OneGate step is on screen. A run can
117
+ * hand over mid-flow (resumed re-authentication, or a run that handed off
118
+ * to a visible browser), so no step is assumed to come first.
119
+ */
120
+ async completeLogin(page) {
121
+ let deadline = Date.now() + (this.config.headless === false ? MFA_TIMEOUT_MS : CHALLENGE_TIMEOUT_MS);
122
+ let credentialsSubmitted = false;
123
+ let challenged = false;
124
+ /** The page the code was submitted from; a reload away from it is a rejection. */
125
+ let codeSubmittedFrom = null;
126
+ let announced = false;
127
+ while (Date.now() < deadline) {
128
+ if (await this.isAuthenticated(page)) {
129
+ log("INFO", "Login successful - verified Brightspace home");
130
+ return;
131
+ }
132
+ if (isOneGate(page.url())) {
133
+ await this.openCredentialsForm(page);
134
+ this.reportStatusMessage(page);
135
+ // The banner is also used for harmless notices before a password is
136
+ // ever sent, so only treat it as a hard rejection once credentials
137
+ // have actually been submitted — otherwise this would abort a login
138
+ // that is still progressing normally.
139
+ if (credentialsSubmitted && !challenged && await this.isVisible(page, SELECTORS.statusMessage)) {
140
+ throw new BrowserAuthError(`OneGate rejected the saved username or password. Run \`${SETUP_COMMAND} --javeriana\` to update your password.`, "credentials");
141
+ }
142
+ if (await this.isEditable(page, SELECTORS.token)) {
143
+ if (!challenged) {
144
+ challenged = true;
145
+ deadline = Date.now() + MFA_TIMEOUT_MS;
146
+ }
147
+ if (this.config.headless === false && !this.config.requestMfaCode) {
148
+ // The person at the window types the code and can retry a typo
149
+ // there, so a rejection is theirs to answer, not a failure here.
150
+ if (!announced) {
151
+ announced = true;
152
+ log("WARN", "Enter the authenticator code in the OneGate browser window. Waiting up to 5 minutes.");
153
+ this.config.onMfaChallenge?.(null);
154
+ }
155
+ }
156
+ else {
157
+ const url = page.url();
158
+ if (codeSubmittedFrom !== null && url !== codeSubmittedFrom) {
159
+ throw new MfaApprovalError(new Error("OneGate rejected the authenticator code."));
160
+ }
161
+ if (codeSubmittedFrom === null) {
162
+ if (this.config.headless !== false && !this.config.requestMfaCode)
163
+ throw this.codePromptUnavailable();
164
+ codeSubmittedFrom = url;
165
+ await this.submitCode(page);
166
+ }
167
+ }
168
+ }
169
+ else if (!credentialsSubmitted && await this.isVisible(page, SELECTORS.username)) {
170
+ // OneGate's authenticator code is required on every full sign-in and
171
+ // there is no way to type it without a terminal or a visible window,
172
+ // so a headless run with neither can never finish. Stop before the
173
+ // password is sent: otherwise a stale saved password keeps getting
174
+ // resubmitted on every background re-authentication attempt.
175
+ if (this.config.headless !== false && !this.config.requestMfaCode)
176
+ throw this.codePromptUnavailable();
177
+ credentialsSubmitted = true;
178
+ await this.enterCredentials(page);
179
+ }
180
+ }
181
+ await page.waitForTimeout(POLL_MS);
182
+ }
183
+ if (challenged)
184
+ throw new MfaApprovalError();
185
+ throw new UnsupportedAuthenticationError("OneGate did not reach the authenticator-code challenge or Brightspace. If your sign-in needs a different step, set D2L_HEADLESS=false and finish sign-in in the browser.");
186
+ }
187
+ /**
188
+ * Open the password form from OneGate's chooser. A no-op once a username
189
+ * or code field is already on screen — a resumed run, or a redirect that
190
+ * landed past the chooser on its own — once this redirect has already
191
+ * run for this login, or when the current page is not actually the
192
+ * chooser: a transient interstitial (a SAML auto-post, a "processing"
193
+ * page between steps) also lacks the username/token fields, and
194
+ * navigating away from it would abort an in-flight POST. The chooser's
195
+ * own "Usuario y Contraseña" link is the positive signal that this really
196
+ * is the chooser, safe to redirect from.
197
+ */
198
+ async openCredentialsForm(page) {
199
+ const hasUsername = (await page.locator(SELECTORS.username).count()) > 0;
200
+ const hasToken = (await page.locator(SELECTORS.token).count()) > 0;
201
+ if (hasUsername || hasToken)
202
+ return;
203
+ if (this.credentialsFormOpened)
204
+ return;
205
+ const hasChooserLink = (await page.locator(SELECTORS.chooserLink).count()) > 0;
206
+ if (!hasChooserLink)
207
+ return;
208
+ this.credentialsFormOpened = true;
209
+ const { origin } = new URL(page.url());
210
+ log("INFO", 'Opening the "Usuario y Contraseña" form');
211
+ await page.goto(`${origin}${CREDENTIALS_FORM_PATH}`, {
212
+ waitUntil: "load",
213
+ timeout: FORM_NAVIGATION_TIMEOUT_MS,
214
+ });
215
+ }
216
+ async enterCredentials(page) {
217
+ if (!this.config.username)
218
+ throw new BrowserAuthError("Username is required for SSO login", "credentials");
219
+ if (!this.config.password)
220
+ throw new BrowserAuthError("Password is required for SSO login", "credentials");
221
+ // The handler that copies typed values into hidden fields attaches on
222
+ // `load`; clicking before then posts the hidden inputs empty.
223
+ await page.waitForLoadState("load");
224
+ // Re-check immediately after the load wait and before either field is
225
+ // touched: a navigation during that wait must never let a lookalike
226
+ // host receive the username, let alone the password.
227
+ this.assertOnOneGate(page, "entering credentials");
228
+ log("INFO", "Entering credentials");
229
+ await page.locator(SELECTORS.username).first().fill(this.config.username);
230
+ await page.locator(SELECTORS.password).first().fill(this.config.password);
231
+ await page.locator(SELECTORS.credentialsSubmit).first().click().catch(() => { });
232
+ }
233
+ codePromptUnavailable() {
234
+ return new UnsupportedAuthenticationError(`OneGate requires an authenticator code. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
235
+ }
236
+ /**
237
+ * Feed OneGate's code challenge from the terminal prompt. Unlike the
238
+ * other code-based flows, which validate once and bail, this re-prompts
239
+ * on a malformed entry — the caller supplying requestMfaCode may not
240
+ * already loop the way the CLI's own terminal prompt does.
241
+ */
242
+ async submitCode(page) {
243
+ if (!this.config.requestMfaCode)
244
+ throw this.codePromptUnavailable();
245
+ let code;
246
+ for (let attempt = 1; attempt <= MAX_CODE_ATTEMPTS; attempt++) {
247
+ const typed = (await this.config.requestMfaCode()).replace(/\s/g, "");
248
+ if (/^\d{6,8}$/.test(typed)) {
249
+ code = typed;
250
+ break;
251
+ }
252
+ log("WARN", "That does not look like a 6-8 digit authenticator code.");
253
+ }
254
+ if (!code)
255
+ throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
256
+ // The same submit-handler timing applies to the code form.
257
+ await page.waitForLoadState("load");
258
+ // Re-check immediately before the code, for the same reason as the password.
259
+ this.assertOnOneGate(page, "entering the authenticator code");
260
+ await page.locator(SELECTORS.token).first().fill(code);
261
+ await page.locator(SELECTORS.tokenSubmit).first().click().catch(() => { });
262
+ log("INFO", "Authenticator code submitted");
263
+ }
264
+ /** Never type a secret into anything but OneGate itself. */
265
+ assertOnOneGate(page, action) {
266
+ if (!isOneGate(page.url())) {
267
+ throw new UnsupportedAuthenticationError(`Javeriana sign-in reached an unsupported identity-provider page while ${action}. No credentials were entered there.`);
268
+ }
269
+ }
270
+ async isVisible(page, selector) {
271
+ return page.locator(selector).first().isVisible().catch(() => false);
272
+ }
273
+ /**
274
+ * Editable rather than visible: the code field is on the page, visible and
275
+ * disabled, before the credentials step is submitted, so waiting for mere
276
+ * visibility would fill a field the page is still ignoring.
277
+ */
278
+ async isEditable(page, selector) {
279
+ return page.locator(selector).first().isEditable().catch(() => false);
280
+ }
281
+ /**
282
+ * Surface OneGate's status banner (wrong password, locked account, ...) in
283
+ * the logs if it appears. Deliberately non-blocking and advisory: the
284
+ * banner is also used for harmless notices, so it must not abort a login
285
+ * that is still progressing.
286
+ */
287
+ reportStatusMessage(page) {
288
+ const banner = page.locator(SELECTORS.statusMessage).first();
289
+ banner
290
+ .isVisible()
291
+ .then(async (visible) => {
292
+ if (!visible)
293
+ return;
294
+ const text = (await banner.textContent().catch(() => null))?.trim();
295
+ if (text)
296
+ log("WARN", `OneGate reported: ${text}`);
297
+ })
298
+ .catch(() => {
299
+ // No banner, or the page navigated away first — both are expected.
300
+ });
301
+ }
302
+ /** The login shell also exposes D2L.LP, so verify origin and home as well. */
303
+ async isAuthenticated(page) {
304
+ try {
305
+ const expected = new URL(this.config.baseUrl ?? `https://${JAVERIANA_BRIGHTSPACE_HOST}`);
306
+ const current = new URL(page.url());
307
+ if (current.origin !== expected.origin || !/^\/d2l\/home(?:\/|$)/.test(current.pathname))
308
+ return false;
309
+ const cookies = await page.context().cookies(expected.origin);
310
+ if (!cookies.some((cookie) => cookie.name === "d2lSessionVal" && Boolean(cookie.value)))
311
+ return false;
312
+ return await page.evaluate(() => {
313
+ const d2l = window.D2L;
314
+ return Boolean(d2l?.LP);
315
+ });
316
+ }
317
+ catch {
318
+ // Redirects can replace the execution context mid-check. Keep polling.
319
+ return false;
320
+ }
321
+ }
322
+ }
@@ -9,6 +9,7 @@ import { WesternSSOFlow, isWesternBrightspace } from "./western-sso.js";
9
9
  import { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
10
10
  import { CunySSOFlow, isCunyBrightspace } from "./cuny-sso.js";
11
11
  import { LeidenSSOFlow, isLeidenBrightspace } from "./leiden-sso.js";
12
+ import { JaverianaSSOFlow, isJaverianaBrightspace } from "./javeriana-sso.js";
12
13
  import { BrowserAuthError } from "../utils/errors.js";
13
14
  import { AUTH_COMMAND } from "../utils/commands.js";
14
15
  export class UnsupportedAuthenticationError extends BrowserAuthError {
@@ -63,5 +64,8 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
63
64
  if (isCunyBrightspace(config.baseUrl)) {
64
65
  return new CunySSOFlow(credentials);
65
66
  }
67
+ if (isJaverianaBrightspace(config.baseUrl)) {
68
+ return new JaverianaSSOFlow(credentials);
69
+ }
66
70
  return new PurdueSSOFlow(credentials);
67
71
  }
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);
@@ -135,7 +135,14 @@ else {
135
135
  registerGetVideoTranscript(server, apiClient);
136
136
  registerGetServerInfo(server, config, PKG_VERSION, apiClient);
137
137
  registerSearchCourse(server, apiClient);
138
- log("DEBUG", "MCP tools registered (19 tools)");
138
+ // Instructor / TA dropbox tools (read-only) — 403 for a student account.
139
+ registerGetDropboxFolders(server, apiClient);
140
+ registerGetDropboxSubmissions(server, apiClient);
141
+ registerGetDropboxUserSubmissions(server, apiClient);
142
+ registerGetDropboxFeedback(server, apiClient);
143
+ registerGetRubricsForObject(server, apiClient);
144
+ registerDownloadDropboxSubmissionFile(server, apiClient);
145
+ log("DEBUG", "MCP tools registered (25 tools)");
139
146
  // Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
140
147
  // picker, distinct from tools. Each one is a canned user message that
141
148
  // names our own tools by name, so a client with no idea what to ask
@@ -148,7 +155,7 @@ else {
148
155
  // Connect stdio transport
149
156
  const transport = new StdioServerTransport();
150
157
  await server.connect(transport);
151
- log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (19 tools, 4 prompts registered)");
158
+ log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (25 tools, 4 prompts registered)");
152
159
  log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
153
160
  }
154
161
  catch (error) {
package/build/setup.js CHANGED
@@ -66,6 +66,12 @@ export const SCHOOL_PRESETS = {
66
66
  mfaNote: "Type the 6-digit code from your authenticator app when prompted.",
67
67
  usernameHint: "Use your full CUNY Login address, e.g. firstname.lastname01@login.cuny.edu",
68
68
  },
69
+ javeriana: {
70
+ name: "Pontificia Universidad Javeriana Cali",
71
+ baseUrl: "https://auladigital.javerianacali.edu.co",
72
+ usernameLabel: "Javeriana username",
73
+ mfaNote: "OneGate asks for a code from your authenticator app when you sign in.",
74
+ },
69
75
  };
70
76
  /**
71
77
  * Pick the school preset named by `--purdue`, `--suny`, `--western`, `--cuny`, etc.
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.9.15",
3
+ "version": "3.9.17",
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",