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 +4 -2
- package/build/auth/index.js +1 -0
- package/build/auth/javeriana-sso.js +322 -0
- package/build/auth/sso-flow.js +4 -0
- package/build/index.js +10 -3
- package/build/setup.js +6 -0
- 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/package.json +1 -1
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 --
|
|
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
|
|
package/build/auth/index.js
CHANGED
|
@@ -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
|
+
}
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
+
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.9.
|
|
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",
|