brightspace-mcp-server 5.1.44 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -89
- package/build/api/client.js +32 -13
- package/build/index.js +6 -2
- package/build/runtime-relay.js +24 -1
- package/build/server.js +3 -2
- package/build/sync/cleanup.js +61 -0
- package/build/sync/course-sync.js +649 -0
- package/build/sync/errors.js +8 -0
- package/build/sync/lock.js +123 -0
- package/build/sync/manifest.js +106 -0
- package/build/sync/names.js +158 -0
- package/build/sync/summary.js +70 -0
- package/build/sync/sync-cli.js +190 -0
- package/build/tools/download-course-files.js +86 -0
- package/build/tools/download-dropbox-submission-file.js +7 -1
- package/build/tools/download-file.js +24 -9
- package/build/tools/get-course-content.js +6 -0
- package/build/tools/get-syllabus.js +5 -0
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +18 -0
- package/build/tools/tool-helpers.js +5 -0
- package/build/utils/download-helpers.js +67 -4
- package/build/utils/sensitive-paths.js +237 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Lets your AI see your Brightspace classes, so you can just ask:
|
|
|
12
12
|
|
|
13
13
|
> "What's due this week?" · "Am I passing all my classes?" · "Summarize today's announcements" · "Turn my lecture slides into flashcards"
|
|
14
14
|
|
|
15
|
-
Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and any other app that supports [MCP](https://modelcontextprotocol.io)
|
|
15
|
+
Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and any other app that supports [MCP](https://modelcontextprotocol.io). It only **reads** your courses. It never submits, edits, or deletes anything.
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
18
|
<img src="https://raw.githubusercontent.com/RohanMuppa/brightspace-mcp-server/main/docs/how-it-works.svg" alt="Architecture diagram" width="100%">
|
|
@@ -20,19 +20,19 @@ Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and a
|
|
|
20
20
|
|
|
21
21
|
## Get started in 3 steps
|
|
22
22
|
|
|
23
|
-
1. **Install [Node.js](https://nodejs.org/)**
|
|
24
|
-
2. **
|
|
23
|
+
1. **Install [Node.js](https://nodejs.org/)** version 22 or newer.
|
|
24
|
+
2. **Run the setup in a terminal:**
|
|
25
25
|
```bash
|
|
26
26
|
npx -y brightspace-mcp-server@latest setup
|
|
27
27
|
```
|
|
28
|
-
It asks for your school's Brightspace address, your username and password, and how you confirm it's you
|
|
28
|
+
It asks for your school's Brightspace address, your username and password, and how you confirm it's you (a phone app, or another way such as a text message). Your password goes into your computer's password store (Keychain on Mac, Credential Manager on Windows), never into a file. Setup also connects the server to Claude Desktop, Cursor, Antigravity, Codex, or Claude Code if you have them. If one already has a `brightspace` entry pointing somewhere else, setup asks before replacing it.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
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
|
+
If your school is in the table below, add its flag (such as `--purdue`) to skip typing the address.
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
**On a Mac:** when Keychain asks whether `node` may use the saved Brightspace password, choose **Always Allow**, not just Allow.
|
|
33
|
+
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.
|
|
34
34
|
|
|
35
|
-
**Rather have the AI install it
|
|
35
|
+
**Rather have the AI install it?** Paste this into Claude Code, Cursor, Windsurf, Copilot, or Codex:
|
|
36
36
|
|
|
37
37
|
```
|
|
38
38
|
Install brightspace-mcp-server for me by following
|
|
@@ -40,9 +40,9 @@ https://github.com/RohanMuppa/brightspace-mcp-server/blob/main/LLMs.md
|
|
|
40
40
|
(use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, --cuny at CUNY, --leiden at Leiden, --mcgill at McGill, --mun at Memorial University, --ngeeann at Ngee Ann Polytechnic, or --javeriana at Javeriana Cali).
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
The AI does everything it can by itself
|
|
43
|
+
The AI does everything it can by itself, then gives you one terminal command. That command asks for your password and waits for you to approve the sign-in on your phone. That's the only part that needs you. Afterwards the AI checks that it can see your courses.
|
|
44
44
|
|
|
45
|
-
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`).
|
|
45
|
+
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`). Codex and Claude Code commands, and general steps, are in [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
46
46
|
|
|
47
47
|
## Does my school work?
|
|
48
48
|
|
|
@@ -60,124 +60,125 @@ If your school uses D2L Brightspace, yes. These have automatic sign-in built in:
|
|
|
60
60
|
| Memorial University of Newfoundland | MUN Login (CAS, no two-factor when tested) | `--mun` |
|
|
61
61
|
| Javeriana Cali | OneGate (password + authenticator code) | `--javeriana` |
|
|
62
62
|
| Ngee Ann Polytechnic | Microsoft Entra | `--ngeeann` |
|
|
63
|
-
| Any other D2L school | Paste your Brightspace address. If the
|
|
63
|
+
| Any other D2L school | Paste your Brightspace address at setup. If the sign-in page isn't recognized, answer 2 to setup's sign-in question to finish it by hand | none |
|
|
64
64
|
|
|
65
|
-
School-specific quirks
|
|
65
|
+
School-specific quirks are in [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
66
66
|
|
|
67
67
|
## Things to ask
|
|
68
68
|
|
|
69
69
|
| About | Try |
|
|
70
70
|
|-------|-----|
|
|
71
|
-
| Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "
|
|
71
|
+
| Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "Is there feedback on my midterm?" (quiz feedback often can't be read through the API, so the answer links to the quiz instead) |
|
|
72
72
|
| Due dates | "What's due in the next 48 hours?" · "Build me a study schedule for the week" |
|
|
73
|
-
| Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" · "Did I turn in HW 3?"
|
|
74
|
-
| Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?"
|
|
73
|
+
| Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" · "Did I turn in HW 3?" |
|
|
74
|
+
| Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" |
|
|
75
75
|
| Announcements | "Did any professor post something important today?" · "Read the file attached to today's announcement" |
|
|
76
|
-
| Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Search this course for office hours" · or
|
|
77
|
-
| Syllabus | "What's the late policy in ECE 264's syllabus?" (if the
|
|
76
|
+
| Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Save this whole course to a folder on my computer" · "Search this course for office hours" · or read a file inline instead of saving it |
|
|
77
|
+
| Syllabus | "What's the late policy in ECE 264's syllabus?" (if the syllabus lives in an external tool, you get the link to open instead) |
|
|
78
78
|
| People | "Who are the TAs for ECE 264?" · "Who is in my project group?" |
|
|
79
79
|
| Discussions | "Summarize the latest posts in the final project thread" |
|
|
80
|
-
| Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura
|
|
80
|
+
| Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura and YouTube. If YouTube asks for a sign-in or "not a bot" check, the answer gives the link: open it, choose "Show transcript", and paste that text here) |
|
|
81
81
|
| Calendar | "When is my midterm?" · "Is lab cancelled on Thursday?" |
|
|
82
|
-
|
|
|
83
|
-
| Signing out | "Sign me out of Brightspace" (your AI app may ask you to confirm first; details under [When it asks you to sign in](#when-it-asks-you-to-sign-in)) |
|
|
82
|
+
| Instructors and TAs | "Which students haven't submitted Lab 4 yet?" (students see "Instructor or TA access required" for these) |
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Apps with a prompt picker (like Claude Desktop) also offer four ready-made prompts, so you can start from one click instead of typing a question:
|
|
84
|
+
If Brightspace hides part of the data, such as a submission, quiz attempts, or part of the quiz list, the answer says "unknown" or "incomplete" rather than "missing" or "complete". Full tool reference: [LLMs.md](LLMs.md).
|
|
88
85
|
|
|
89
|
-
|
|
90
|
-
|--------|-----------|------------------|
|
|
91
|
-
| `weekly_briefing` | — | A 7-day rollup of what's due, what's new, and what changed in your grades, across every course |
|
|
92
|
-
| `grade_audit` | `courseId` (optional) | Analyzes grades for one course, or all of them, and flags missing or low-scoring items |
|
|
93
|
-
| `study_planner` | `daysAhead` (optional, default 7) | Plans study time from upcoming due dates and calendar events |
|
|
94
|
-
| `course_summary` | `courseId` (required) | Syllabus, content outline, assignments, and grades for one course |
|
|
86
|
+
## Download a whole course
|
|
95
87
|
|
|
96
|
-
|
|
88
|
+
Ask your assistant to save a course, or run it yourself from a terminal:
|
|
97
89
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|---------|--------------|
|
|
102
|
-
| `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor (off by default; not for shared computers). `setup` asks this as option 2 of its one "how often do you want to approve sign-ins" question and saves it as `rememberMfa` in `config.json`; the env var overrides it ([details](docs/sign-in.md#fewer-2fa-prompts)) |
|
|
103
|
-
| `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft Entra "enter a code" challenge is answered on this computer instead of on your phone (off by default). Prefer `setup` (option 3), which keeps the key in your OS keychain; this variable is for CI and containers with no keychain and is weaker, because anything that can read the process environment gets your second factor ([details](docs/sign-in.md#fewer-2fa-prompts)) |
|
|
104
|
-
| `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
|
|
105
|
-
| `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
|
|
106
|
-
| `D2L_SESSION_COOKIE` / `D2L_ACCESS_TOKEN` | Skip the browser entirely with a pasted cookie or token ([how](docs/sign-in.md#no-browser-paste-a-session-cookie-or-token)) |
|
|
107
|
-
| `D2L_INCLUDE_COURSES` / `D2L_EXCLUDE_COURSES` | Limit which courses the AI sees, by course id |
|
|
108
|
-
| `D2L_ACTIVE_ONLY=false` | Include courses whose enrollment has ended |
|
|
109
|
-
| `D2L_CURRENT_ONLY=true` | Only show courses in their current enrollment window, like Brightspace's "Current Courses" widget |
|
|
110
|
-
| `D2L_NO_UPDATE_CHECK=1` | Turn off the new-version notice |
|
|
111
|
-
| `D2L_RUNTIME_IDLE_SECONDS` | How long the shared background server waits with no question running before it stops (default 600) |
|
|
112
|
-
| `D2L_DEV_MODE=true` | Write a local, content-free log of tool calls and sign-in events, for debugging session problems ([how](docs/dev-mode.md)) |
|
|
113
|
-
|
|
114
|
-
## One server for all your chats
|
|
90
|
+
```bash
|
|
91
|
+
npx -y brightspace-mcp-server@latest sync --course 123456 --dest ~/Courses/ECE-264
|
|
92
|
+
```
|
|
115
93
|
|
|
116
|
-
Every
|
|
94
|
+
Every file in the course's Content area is saved into folders named after its modules. Running it again fetches only what is new.
|
|
117
95
|
|
|
118
|
-
|
|
96
|
+
| Option | What it does |
|
|
97
|
+
|--------|--------------|
|
|
98
|
+
| `--existing skip\|replace\|keep_both` | A file already there: `skip` (default) leaves it, `replace` re-downloads only files the sync saved and you haven't edited, `keep_both` saves a different copy as `name (1).ext` |
|
|
99
|
+
| `--module <id>` | Only that module and the modules inside it |
|
|
100
|
+
| `--dry-run` | List what would be downloaded, and download nothing |
|
|
101
|
+
| `--max-files <n>` | Most files per run (default 300). Run it again to continue |
|
|
102
|
+
| `--json` | Print the result as JSON on stdout, for scripts |
|
|
119
103
|
|
|
120
|
-
|
|
104
|
+
- The sync keeps a `.brightspace-sync.json` manifest in the folder, which is how it knows which files are its own. Only one sync runs in a folder at a time. If a crashed run left its `.brightspace-sync.lock` behind, the next run takes it over.
|
|
105
|
+
- Course names are made safe for your computer, and a course can't create dotfiles such as `.zshrc` in your folder.
|
|
106
|
+
- Each run is capped at 300 files and 5 GB. The assistant's version also stops cleanly after 8 minutes. Ask it again to continue. Ctrl-C stops the CLI cleanly (exit 130) with no partial file left behind.
|
|
107
|
+
- Exit codes: 0 when everything was done, 1 if a file failed or wasn't attempted (including a sign-in that expired partway; saved files are kept), 2 for a usage error. Links and embedded videos aren't files, so the result counts them separately rather than as missing downloads.
|
|
121
108
|
|
|
122
|
-
|
|
109
|
+
**Some folders are refused.** Downloads can't go to your home folder itself, a drive root, a hidden (dot) folder such as `~/.ssh`, `~/Library` (except cloud-drive and iCloud folders), system folders such as `/etc` or `/System`, or on Windows `Windows`, `Program Files`, `ProgramData` and `AppData`. Anything else is fine, including `/tmp`, external drives and `~/Documents`. Choose a folder like `~/Documents/Brightspace`; the refusal names the rule. The same rule applies to `download_file`, `get_syllabus` and every other tool that saves to disk. The person running the server can allow a folder anyway with `D2L_DOWNLOAD_ROOTS` (see [Settings](#settings-you-might-want)). Full rules: [LLMs.md](LLMs.md).
|
|
123
110
|
|
|
124
|
-
|
|
125
|
-
```bash
|
|
126
|
-
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
127
|
-
```
|
|
128
|
-
Save it as a user environment variable named `D2L_HTTP_TOKEN`, where both the server and Codex can see it. On Windows: Start → "Edit environment variables for your account" → New, paste it as the value (apps and terminals opened afterwards see it). On macOS or Linux: add `export D2L_HTTP_TOKEN=<token>` to your shell profile (`~/.zshrc`, `~/.bashrc`); a Codex app launched from the Dock or a desktop menu may not read that profile, so start it from a terminal. The server never prints the token.
|
|
129
|
-
2. **Start the server and leave it running** in a terminal of its own:
|
|
130
|
-
```bash
|
|
131
|
-
npx -y brightspace-mcp-server@latest http
|
|
132
|
-
```
|
|
133
|
-
This needs a release with HTTP support; to try it from a source checkout first, run `npm run build`, then `node build/index.js http`. It listens on `http://127.0.0.1:18765/mcp`, on this computer only. Pick another port with `--port 18766` or `D2L_HTTP_PORT`, and use that same port in the URL in step 3. Start it once per login: chats do not start it, and a second copy on the same port stops at once with "already in use" rather than running alongside the first. Nothing signs in at startup.
|
|
134
|
-
3. **Point Codex at it.** In `~/.codex/config.toml`, replace the `command` and `args` lines of your `[mcp_servers.brightspace]` entry with:
|
|
135
|
-
```toml
|
|
136
|
-
[mcp_servers.brightspace]
|
|
137
|
-
url = "http://127.0.0.1:18765/mcp"
|
|
138
|
-
bearer_token_env_var = "D2L_HTTP_TOKEN"
|
|
139
|
-
```
|
|
140
|
-
(`env_http_headers = { "Authorization" = "D2L_HTTP_AUTHORIZATION" }` also works, if that variable holds `Bearer <token>`.) A project's own `.codex/config.toml` overrides this one, so remove any `brightspace` entry there or change it the same way. Rerunning `setup` recognizes this entry and keeps it, token settings included.
|
|
141
|
-
4. **Restart Codex.** Chats that were already open keep the old per-chat server until Codex restarts. If you stop or restart the shared server, calls fail until it is back; if they still fail after that, start a new chat or restart Codex.
|
|
111
|
+
## Prompts
|
|
142
112
|
|
|
143
|
-
|
|
144
|
-
- **Settings go to the server, not to Codex.** A URL entry has no process to pass an `env` table to, so `D2L_*` settings (the [table above](#settings-you-might-want)) are set in the terminal that runs `http`, in a `.env` file in the directory you start it from, or saved by `setup`.
|
|
145
|
-
- **Sign-in messages show up in the server's terminal.** The number to approve still comes back in the chat's answer, and, in clients that show tool progress, while the question is running.
|
|
146
|
-
- **Stopping it:** press Ctrl+C. Questions already running finish first; press Ctrl+C again to stop at once.
|
|
147
|
-
- **Updating it:** restart the server, not Codex. Restarting a client only reconnects it to the server that is already running the old version. Stop the server and start the same `http` command again with the same `D2L_HTTP_TOKEN`, port, and settings; from a source checkout, run `npm run update` first. Update notices from a shared server say this too.
|
|
113
|
+
Apps with a prompt picker (like Claude Desktop) also offer four one-click prompts:
|
|
148
114
|
|
|
149
|
-
|
|
115
|
+
- `weekly_briefing`: a 7-day rollup of what's due, what's new, and what changed in your grades
|
|
116
|
+
- `grade_audit` (optional `courseId`): flags missing or low-scoring items
|
|
117
|
+
- `study_planner` (optional `daysAhead`, default 7): plans study time from due dates and calendar events
|
|
118
|
+
- `course_summary` (required `courseId`): syllabus, outline, assignments and grades for one course
|
|
150
119
|
|
|
151
120
|
## When it asks you to sign in
|
|
152
121
|
|
|
153
|
-
There's no separate login
|
|
122
|
+
There's no separate login. Asking a question signs you in, and it stays signed in on its own most days. When your school wants two-factor again, the number to approve shows up in the answer. Approve it on your phone, and the assistant keeps checking, so the original question completes as soon as sign-in does. If it gets stuck, run this in a terminal:
|
|
154
123
|
|
|
155
124
|
```bash
|
|
156
125
|
npx -y brightspace-mcp-server@latest auth
|
|
157
126
|
```
|
|
158
127
|
|
|
159
|
-
|
|
128
|
+
How often your school asks for two-factor is up to the school, not this tool.
|
|
129
|
+
|
|
130
|
+
**On a Mac,** a one-time **Allow** at the Keychain prompt can make later sign-ins from your AI app or another terminal fail or hang. Pick **Always Allow** instead. That permission only lets the local Brightspace server read its own saved sign-in password, nothing else in your Keychain.
|
|
131
|
+
|
|
132
|
+
**Want fewer 2FA prompts?** `setup` asks **How often do you want to approve sign-ins on your phone?** Pick **1** every time (the default), **2** to remember this computer, or **3** to let it answer Microsoft's code requests (needs an authenticator setup key). Options 2 and 3 are not for shared computers. Steps and tradeoffs: [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
|
|
160
133
|
|
|
161
|
-
**
|
|
134
|
+
**To sign in** from a chat, ask your AI app to sign you in to Brightspace. It can start the sign-in itself, and opens a window for you when no password is saved.
|
|
162
135
|
|
|
163
|
-
**To sign
|
|
136
|
+
**To sign out early** (on a shared computer, say), ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout`. This deletes this computer's saved session, so the next question does a full sign-in. Your password and authenticator key are kept. It doesn't sign you out of Microsoft or Brightspace elsewhere. On a shared computer, also delete the saved `brightspace-mcp-server` entries from Keychain Access (Mac) or Credential Manager (Windows). Details: [docs/sign-in.md](docs/sign-in.md#signing-out-early).
|
|
164
137
|
|
|
165
|
-
|
|
138
|
+
Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) are covered in [docs/sign-in.md](docs/sign-in.md).
|
|
139
|
+
|
|
140
|
+
## Settings you might want
|
|
166
141
|
|
|
167
|
-
|
|
142
|
+
All optional. Set them in your AI app's MCP `env` config or in your shell. The full list is in [LLMs.md](LLMs.md#environment-variables).
|
|
143
|
+
|
|
144
|
+
| Setting | What it does |
|
|
145
|
+
|---------|--------------|
|
|
146
|
+
| `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor. Off by default, and not for shared computers. `setup` option 2 saves the same choice |
|
|
147
|
+
| `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft code challenge is answered on this computer. Prefer `setup` option 3, which keeps the key in your OS keychain. Environment only for CI and containers: anything that can read the environment gets your second factor ([details](docs/sign-in.md#fewer-2fa-prompts)) |
|
|
148
|
+
| `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
|
|
149
|
+
| `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
|
|
150
|
+
| `D2L_SESSION_COOKIE` / `D2L_ACCESS_TOKEN` | Skip the browser entirely with a pasted cookie or token. See [docs/sign-in.md](docs/sign-in.md#no-browser-paste-a-session-cookie-or-token) |
|
|
151
|
+
| `D2L_INCLUDE_COURSES` / `D2L_EXCLUDE_COURSES` | Comma-separated course ids to show or hide |
|
|
152
|
+
| `D2L_ACTIVE_ONLY=false` | Include courses whose enrollment has ended |
|
|
153
|
+
| `D2L_CURRENT_ONLY=true` | Only show courses in their current enrollment window (Brightspace's "Current Courses" widget) |
|
|
154
|
+
| `D2L_DOWNLOAD_ROOTS=/data:/srv/courses` | Folders downloads may always use, even where the refusals above say no. Separate paths with `:` on macOS and Linux, `;` on Windows. Only the person running the server can set it |
|
|
155
|
+
| `D2L_NO_UPDATE_CHECK=1` | Turn off the new-version notice |
|
|
156
|
+
| `D2L_DEV_MODE=true` | Write a local, content-free log of tool calls and sign-in events, for debugging ([how](docs/dev-mode.md)) |
|
|
157
|
+
|
|
158
|
+
## One server for all your chats
|
|
159
|
+
|
|
160
|
+
Every chat shares one Brightspace server automatically, with one sign-in, one session and one cache. Closing a chat never interrupts another. Codex users, or anyone who wants to run one server themselves, can follow [docs/shared-server.md](docs/shared-server.md).
|
|
161
|
+
|
|
162
|
+
## Privacy and security
|
|
163
|
+
|
|
164
|
+
Your password is stored in your computer's password store, never in a plaintext file. Saved session state is encrypted with AES-256-GCM. Approvals (the tap or number) stay on your phone, and the server never sees them. A code you type, or one generated from a saved authenticator key, goes only to the sign-in page. None of the tools submit, edit or delete anything in your courses. More detail: [docs/sign-in.md](docs/sign-in.md#whats-stored-where).
|
|
168
165
|
|
|
169
166
|
## Something not working?
|
|
170
167
|
|
|
171
|
-
- Run `npx -y brightspace-mcp-server@latest doctor` first
|
|
172
|
-
- **Browser removed
|
|
173
|
-
-
|
|
174
|
-
-
|
|
175
|
-
-
|
|
176
|
-
- **
|
|
177
|
-
- **The app says the server timed out while starting:** open the app's MCP server log. Each startup step prints a timestamped `Startup: ...` line before it begins and `done in N ms` when it ends, and a step still running after 10 seconds prints `Startup: still ...` once. The last such line names the step it was stuck in, often the saved-password lookup in your password store. These lines never include your username, password, or cookies.
|
|
168
|
+
- Run `npx -y brightspace-mcp-server@latest doctor` first. It checks the whole setup and tells you what to fix. For a global install, use `brightspace-mcp-server doctor`.
|
|
169
|
+
- **Browser removed** (say, by a disk cleaner): sign-in restores it once by itself. To do it now, run `npx -y brightspace-mcp-server@latest repair`. It doesn't sign in or change your AI apps.
|
|
170
|
+
- **`npx` fails with `EPERM` on a Mac:** run it from your home folder. macOS blocks `npx` from Documents, Desktop and Downloads unless you grant Files and Folders access.
|
|
171
|
+
- **`Authentication failed: The native credential store is locked or unavailable.`** Unlock your password store (on a Mac, log in or unlock Keychain Access), run `npx -y brightspace-mcp-server@latest auth` again (or `setup` if that still fails), and choose **Always Allow** when Keychain asks.
|
|
172
|
+
- **Works in a terminal but not in the app:** the app starts the server separately and may not see your password store yet.
|
|
173
|
+
- **The app says the server timed out while starting:** see the startup notes in [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
178
174
|
- **Which version do I have?** Ask your AI "which version of the Brightspace server am I running?"
|
|
175
|
+
- **Anything else:** see [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
179
176
|
- **Still stuck?** [Open an issue](https://github.com/RohanMuppa/brightspace-mcp-server/issues) and paste what the terminal printed.
|
|
180
177
|
|
|
181
178
|
## For developers
|
|
182
179
|
|
|
183
|
-
Tool reference, response fields, and
|
|
180
|
+
Tool reference, response fields, environment variables and the codebase map: [LLMs.md](LLMs.md). What's safe to build against: [STABILITY.md](STABILITY.md).
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT. See [LICENSE](LICENSE).
|
package/build/api/client.js
CHANGED
|
@@ -293,7 +293,9 @@ export class D2LApiClient {
|
|
|
293
293
|
// Join an identical GET already in flight rather than issuing a second
|
|
294
294
|
// one. Keyed on the same unresolved path the cache above uses, so the
|
|
295
295
|
// join happens before version discovery or a token is even touched.
|
|
296
|
-
|
|
296
|
+
// A caller with a signal is never joined to another's request (its abort
|
|
297
|
+
// must end only its own) and never joined by one.
|
|
298
|
+
const pending = options?.signal ? undefined : this.pendingGets.get(path);
|
|
297
299
|
if (pending) {
|
|
298
300
|
this.statsData.coalescedJoins++;
|
|
299
301
|
log("DEBUG", `Coalescing GET onto an in-flight request: ${path}`);
|
|
@@ -302,7 +304,7 @@ export class D2LApiClient {
|
|
|
302
304
|
const generation = this.cacheGeneration;
|
|
303
305
|
const inFlight = (async () => {
|
|
304
306
|
const resolved = await this.resolvePath(path);
|
|
305
|
-
const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
|
|
307
|
+
const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token, options?.signal), options?.signal);
|
|
306
308
|
if (options?.ttl && generation === this.cacheGeneration) {
|
|
307
309
|
this.cache.set(path, data, options.ttl);
|
|
308
310
|
log("DEBUG", `Cached response for ${path} (TTL: ${options.ttl}ms)`);
|
|
@@ -312,7 +314,8 @@ export class D2LApiClient {
|
|
|
312
314
|
if (this.pendingGets.get(path) === inFlight)
|
|
313
315
|
this.pendingGets.delete(path);
|
|
314
316
|
});
|
|
315
|
-
|
|
317
|
+
if (!options?.signal)
|
|
318
|
+
this.pendingGets.set(path, inFlight);
|
|
316
319
|
return inFlight;
|
|
317
320
|
}
|
|
318
321
|
/**
|
|
@@ -325,9 +328,10 @@ export class D2LApiClient {
|
|
|
325
328
|
* @throws ApiError on HTTP errors (401, 403, 429, etc.)
|
|
326
329
|
* @throws NetworkError on network/fetch failures
|
|
327
330
|
*/
|
|
328
|
-
async getRaw(path) {
|
|
331
|
+
async getRaw(path, options) {
|
|
332
|
+
options?.signal?.throwIfAborted();
|
|
329
333
|
const resolved = await this.resolvePath(path);
|
|
330
|
-
return this.withAuthentication(resolved, token => this.makeRawRequest(resolved, token));
|
|
334
|
+
return this.withAuthentication(resolved, token => this.makeRawRequest(resolved, token, options?.signal), options?.signal);
|
|
331
335
|
}
|
|
332
336
|
/**
|
|
333
337
|
* Fetch a Brightspace web page (not an API route) as the signed-in user.
|
|
@@ -379,14 +383,14 @@ export class D2LApiClient {
|
|
|
379
383
|
}
|
|
380
384
|
}
|
|
381
385
|
/** One HTTP refresh and at most one browser login per caller. */
|
|
382
|
-
async withAuthentication(path, request) {
|
|
386
|
+
async withAuthentication(path, request, signal) {
|
|
383
387
|
let token = await this.tokenManager.getToken();
|
|
384
388
|
let authenticated = false;
|
|
385
389
|
if (!token) {
|
|
386
390
|
token = await this.tryAutoReauth(path);
|
|
387
391
|
authenticated = true;
|
|
388
392
|
}
|
|
389
|
-
const send = (current) => this.retrying(() => this.throttled(() => request(current)));
|
|
393
|
+
const send = (current) => this.retrying(() => this.throttled(() => request(current)), signal);
|
|
390
394
|
try {
|
|
391
395
|
return await send(token);
|
|
392
396
|
}
|
|
@@ -446,9 +450,24 @@ export class D2LApiClient {
|
|
|
446
450
|
return fn();
|
|
447
451
|
}
|
|
448
452
|
/** Retry 429, 5xx, and network failures. A 401 is never retried here. */
|
|
449
|
-
retrying(fn) {
|
|
450
|
-
|
|
453
|
+
retrying(fn, signal) {
|
|
454
|
+
if (!signal) {
|
|
455
|
+
return withRetry(fn, { ...this.retryConfig, shouldRetry: isRetryableFailure, retryAfterMs: retryAfterMsFrom });
|
|
456
|
+
}
|
|
457
|
+
// A caller that has given up gets no further attempt and does not sit out a backoff.
|
|
458
|
+
const sleep = this.retryConfig.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
459
|
+
return withRetry(() => {
|
|
460
|
+
signal.throwIfAborted();
|
|
461
|
+
return fn();
|
|
462
|
+
}, {
|
|
451
463
|
...this.retryConfig,
|
|
464
|
+
sleep: ms => new Promise((resolve, reject) => {
|
|
465
|
+
if (signal.aborted)
|
|
466
|
+
return reject(signal.reason);
|
|
467
|
+
const onAbort = () => reject(signal.reason);
|
|
468
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
469
|
+
sleep(ms).then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
470
|
+
}),
|
|
452
471
|
shouldRetry: isRetryableFailure,
|
|
453
472
|
retryAfterMs: retryAfterMsFrom,
|
|
454
473
|
});
|
|
@@ -478,7 +497,7 @@ export class D2LApiClient {
|
|
|
478
497
|
/**
|
|
479
498
|
* Make one HTTP request. Authentication recovery is shared with raw downloads.
|
|
480
499
|
*/
|
|
481
|
-
async makeRequest(path, token) {
|
|
500
|
+
async makeRequest(path, token, signal) {
|
|
482
501
|
const url = `${this.baseUrl}${path}`;
|
|
483
502
|
const headers = this.buildAuthHeaders(token);
|
|
484
503
|
try {
|
|
@@ -486,7 +505,7 @@ export class D2LApiClient {
|
|
|
486
505
|
const response = await fetch(url, {
|
|
487
506
|
method: "GET",
|
|
488
507
|
headers,
|
|
489
|
-
signal: AbortSignal.timeout(this.timeoutMs),
|
|
508
|
+
signal: signal ? AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) : AbortSignal.timeout(this.timeoutMs),
|
|
490
509
|
});
|
|
491
510
|
this.recordStatus(response.status, path);
|
|
492
511
|
// Preserve cookie material: a 401 only rejects this access token.
|
|
@@ -543,7 +562,7 @@ export class D2LApiClient {
|
|
|
543
562
|
/**
|
|
544
563
|
* Make one HTTP request for raw binary data.
|
|
545
564
|
*/
|
|
546
|
-
async makeRawRequest(path, token) {
|
|
565
|
+
async makeRawRequest(path, token, signal) {
|
|
547
566
|
const url = `${this.baseUrl}${path}`;
|
|
548
567
|
const headers = this.buildAuthHeaders(token);
|
|
549
568
|
// A file can take far longer than timeoutMs to arrive. Time out on a
|
|
@@ -554,7 +573,7 @@ export class D2LApiClient {
|
|
|
554
573
|
const response = await fetch(url, {
|
|
555
574
|
method: "GET",
|
|
556
575
|
headers,
|
|
557
|
-
signal: idle.signal,
|
|
576
|
+
signal: signal ? AbortSignal.any([idle.signal, signal]) : idle.signal,
|
|
558
577
|
});
|
|
559
578
|
this.recordStatus(response.status, path);
|
|
560
579
|
// Preserve cookie material for the shared HTTP refresh path.
|
package/build/index.js
CHANGED
|
@@ -26,7 +26,7 @@ const PKG_VERSION = (() => {
|
|
|
26
26
|
})();
|
|
27
27
|
// ── Subcommand routing (before any MCP initialization) ──────────────
|
|
28
28
|
const subcommand = process.argv[2];
|
|
29
|
-
const COMMANDS = ['setup', 'auth', 'doctor', 'repair', 'http', 'runtime'];
|
|
29
|
+
const COMMANDS = ['setup', 'auth', 'doctor', 'repair', 'sync', 'http', 'runtime'];
|
|
30
30
|
// A mistyped command (`htp`) must not quietly start a stdio server that sits
|
|
31
31
|
// waiting on stdin. Flags alone still mean the default stdio server.
|
|
32
32
|
if (subcommand !== undefined && !subcommand.startsWith('-') && !COMMANDS.includes(subcommand)) {
|
|
@@ -38,6 +38,7 @@ if (subcommand !== undefined && !subcommand.startsWith('-') && !COMMANDS.include
|
|
|
38
38
|
' setup configure your school and sign-in\n' +
|
|
39
39
|
' auth sign in to Brightspace now\n' +
|
|
40
40
|
' doctor check the install and configuration\n' +
|
|
41
|
+
' sync download every file of a course (sync --help for options)\n' +
|
|
41
42
|
' repair restore and verify this installation\'s Chromium\n');
|
|
42
43
|
process.exit(2);
|
|
43
44
|
}
|
|
@@ -50,6 +51,9 @@ else if (subcommand === 'auth') {
|
|
|
50
51
|
else if (subcommand === 'doctor') {
|
|
51
52
|
await import('./doctor.js');
|
|
52
53
|
}
|
|
54
|
+
else if (subcommand === 'sync') {
|
|
55
|
+
await (await import('./sync/sync-cli.js')).main(PKG_VERSION);
|
|
56
|
+
}
|
|
53
57
|
else if (subcommand === 'repair') {
|
|
54
58
|
await (await import('./repair.js')).main();
|
|
55
59
|
}
|
|
@@ -138,7 +142,7 @@ else {
|
|
|
138
142
|
const { resolveHttpPort, resolveHttpToken, startHttpServer } = await import("./http-server.js");
|
|
139
143
|
const running = await startHttpServer({ runtime, token: resolveHttpToken(), port: resolveHttpPort() });
|
|
140
144
|
stopServing = running.close;
|
|
141
|
-
log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (
|
|
145
|
+
log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (29 tools, 4 prompts registered)`);
|
|
142
146
|
log("INFO", "Leave this running; every MCP client on this computer connects to that URL. Press Ctrl+C to stop.");
|
|
143
147
|
}
|
|
144
148
|
catch (error) {
|
package/build/runtime-relay.js
CHANGED
|
@@ -35,6 +35,13 @@ class Unreachable extends Error {
|
|
|
35
35
|
*/
|
|
36
36
|
export async function relaySharedRuntime(transport, { dir, startRuntime, startTimeoutMs = DEFAULT_START_TIMEOUT_MS }) {
|
|
37
37
|
const disconnected = new AbortController();
|
|
38
|
+
/**
|
|
39
|
+
* Each request is its own POST, so the runtime cannot match a later
|
|
40
|
+
* notifications/cancelled to it. The relay can: it keeps the POST of every
|
|
41
|
+
* request in progress and aborts it when the client cancels that request.
|
|
42
|
+
* The runtime ends the tool's own signal when its HTTP request goes away.
|
|
43
|
+
*/
|
|
44
|
+
const inProgress = new Map();
|
|
38
45
|
let current;
|
|
39
46
|
let locating;
|
|
40
47
|
/** The address to use, after `failed` (if given) did not answer. */
|
|
@@ -71,6 +78,15 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
|
|
|
71
78
|
};
|
|
72
79
|
const forward = async (message) => {
|
|
73
80
|
const id = "method" in message && "id" in message ? message.id : undefined;
|
|
81
|
+
if ("method" in message && message.method === "notifications/cancelled") {
|
|
82
|
+
const target = message.params?.requestId;
|
|
83
|
+
if (target !== undefined)
|
|
84
|
+
inProgress.get(target)?.abort();
|
|
85
|
+
}
|
|
86
|
+
const cancelled = new AbortController();
|
|
87
|
+
if (id !== undefined)
|
|
88
|
+
inProgress.set(id, cancelled);
|
|
89
|
+
const signal = AbortSignal.any([disconnected.signal, cancelled.signal]);
|
|
74
90
|
let answered = false;
|
|
75
91
|
const reply = (out) => {
|
|
76
92
|
if (id !== undefined && "id" in out && out.id === id && !("method" in out))
|
|
@@ -82,7 +98,7 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
|
|
|
82
98
|
for (let attempt = 1;; attempt += 1) {
|
|
83
99
|
const address = await locate(failed);
|
|
84
100
|
try {
|
|
85
|
-
await post(address, message,
|
|
101
|
+
await post(address, message, signal, reply);
|
|
86
102
|
if (id !== undefined && !answered)
|
|
87
103
|
throw new Error("the runtime ended the call without answering it");
|
|
88
104
|
return;
|
|
@@ -98,10 +114,17 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
|
|
|
98
114
|
catch (error) {
|
|
99
115
|
if (disconnected.signal.aborted)
|
|
100
116
|
return;
|
|
117
|
+
// The client cancelled this request: it is no longer waiting for an answer.
|
|
118
|
+
if (cancelled.signal.aborted)
|
|
119
|
+
return;
|
|
101
120
|
log("ERROR", "Relaying to the shared runtime failed", error);
|
|
102
121
|
if (id !== undefined && !answered)
|
|
103
122
|
reply(failure(id, error));
|
|
104
123
|
}
|
|
124
|
+
finally {
|
|
125
|
+
if (id !== undefined && inProgress.get(id) === cancelled)
|
|
126
|
+
inProgress.delete(id);
|
|
127
|
+
}
|
|
105
128
|
};
|
|
106
129
|
transport.onmessage = (message) => { void forward(message); };
|
|
107
130
|
transport.onclose = () => disconnected.abort();
|
package/build/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { D2LApiClient } from "./api/index.js";
|
|
|
9
9
|
import { trackToolActivity } from "./utils/dev-activity.js";
|
|
10
10
|
import { log } from "./utils/logger.js";
|
|
11
11
|
import { envCredentialRejectedMessage, ENV_COOKIE_REJECTED } from "./utils/commands.js";
|
|
12
|
-
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetAvailableGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
12
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerDownloadCourseFiles, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetAvailableGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
13
13
|
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
14
14
|
/**
|
|
15
15
|
* Build the shared runtime. Nothing here reaches Brightspace: API versions
|
|
@@ -85,6 +85,7 @@ export function createMcpServer(runtime) {
|
|
|
85
85
|
registerGetAnnouncementFiles(server, apiClient);
|
|
86
86
|
registerGetCourseContent(server, apiClient);
|
|
87
87
|
registerDownloadFile(server, apiClient, config.envSessionCookie ? ENV_COOKIE_REJECTED : config.envAccessToken ? COURSE_FILE_NEEDS_COOKIE : undefined);
|
|
88
|
+
registerDownloadCourseFiles(server, apiClient);
|
|
88
89
|
registerGetClasslistEmails(server, apiClient);
|
|
89
90
|
registerGetRoster(server, apiClient);
|
|
90
91
|
registerGetMyGroups(server, apiClient);
|
|
@@ -103,7 +104,7 @@ export function createMcpServer(runtime) {
|
|
|
103
104
|
registerGetDropboxFeedback(server, apiClient);
|
|
104
105
|
registerGetRubricsForObject(server, apiClient);
|
|
105
106
|
registerDownloadDropboxSubmissionFile(server, apiClient);
|
|
106
|
-
log("DEBUG", "MCP tools registered (
|
|
107
|
+
log("DEBUG", "MCP tools registered (29 tools)");
|
|
107
108
|
// Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
|
|
108
109
|
// picker, distinct from tools. Each one is a canned user message that
|
|
109
110
|
// names our own tools by name, so a client with no idea what to ask
|
|
@@ -0,0 +1,61 @@
|
|
|
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 fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { log } from "../utils/logger.js";
|
|
9
|
+
/** A partial download older than this belongs to a run that was killed, not one still going. */
|
|
10
|
+
export const PARTIAL_MAX_AGE_MS = 60 * 60 * 1000;
|
|
11
|
+
/** Exactly what secureStreamDownload names its temporary file. */
|
|
12
|
+
const PARTIAL = /^\.download-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.part$/;
|
|
13
|
+
const MAX_VISITED = 50_000;
|
|
14
|
+
const MAX_DEPTH = 64;
|
|
15
|
+
/**
|
|
16
|
+
* Remove the `.download-<uuid>.part` files a killed run (kill -9, power loss)
|
|
17
|
+
* left in the destination tree. Only that exact name, only regular files
|
|
18
|
+
* older than an hour, and nothing is followed through a symbolic link or into
|
|
19
|
+
* a hidden folder. Returns how many were removed.
|
|
20
|
+
*/
|
|
21
|
+
export async function removeStalePartials(root, now = Date.now()) {
|
|
22
|
+
let removed = 0;
|
|
23
|
+
let visited = 0;
|
|
24
|
+
const walk = async (dir, depth) => {
|
|
25
|
+
if (depth > MAX_DEPTH || visited > MAX_VISITED)
|
|
26
|
+
return;
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (++visited > MAX_VISITED)
|
|
36
|
+
return;
|
|
37
|
+
const full = path.join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
if (!entry.name.startsWith("."))
|
|
40
|
+
await walk(full, depth + 1);
|
|
41
|
+
}
|
|
42
|
+
else if (entry.isFile() && PARTIAL.test(entry.name)) {
|
|
43
|
+
try {
|
|
44
|
+
// lstat again: the entry type above could have changed, and a link is never touched.
|
|
45
|
+
const stat = await fs.lstat(full);
|
|
46
|
+
if (stat.isFile() && now - stat.mtimeMs > PARTIAL_MAX_AGE_MS) {
|
|
47
|
+
await fs.rm(full, { force: true });
|
|
48
|
+
removed++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Gone already, or not ours to remove.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
await walk(root, 0);
|
|
58
|
+
if (removed > 0)
|
|
59
|
+
log("INFO", `Course sync: removed ${removed} partial download(s) left by an interrupted run`);
|
|
60
|
+
return removed;
|
|
61
|
+
}
|