brightspace-mcp-server 5.1.43 → 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 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) (the standard way to plug tools into an AI). It only **reads** your courses — it never submits, edits, or deletes anything.
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/)** (version 22 or newer) if you don't have it. Download, run the installer, done.
24
- 2. **Open a terminal and run the setup:**
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 when you sign in (a phone app, or another way like a text message). Your password goes into your computer's own password store (Keychain on Mac, Credential Manager on Windows), never into a file. Before signing you in, it connects itself to Claude Desktop, Cursor, Antigravity, Codex, or Claude Code if you have them. If one of them already has a `brightspace` entry pointing somewhere else (an old local build, a fork), setup shows you that entry and asks before replacing it.
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
- **On a Mac:** when Keychain asks whether `node` may use the saved Brightspace password, choose **Always Allow**, not just Allow. That lets later sign-ins (the `auth` command and questions from your AI app) read it without asking again.
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
- At one of these schools, add the flag and skip typing the address: `--purdue`, `--suny`, `--western`, `--tudelft`, `--cuny`, `--leiden`, `--mcgill`, `--mun`, `--ngeeann`, `--javeriana`.
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 for you?** Paste this into Claude Code, Cursor, Windsurf, Copilot, or Codex:
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: it checks your computer, downloads the browser sign-in uses, and connects your AI apps. Then it gives you one command to run in a terminal. 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.
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`). Exact steps for each app: [docs/troubleshooting.md](docs/troubleshooting.md).
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 login page isn't recognized, pick option 2 at setup's sign-in question so the page opens for you to finish by hand | none |
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: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
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" · "What did my professor say about Mini-Assignment 1?" (own assignment feedback is read from every route Brightspace documents for students) · "Is there feedback on my midterm?" (quiz-scored grades link to the quiz, since quiz feedback, including feedback only viewable in LockDown Browser, isn't readable through the API) |
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?" (if Brightspace won't give students their own submission list, the assignment's feedback page is checked instead; when neither shows a submission it's reported as unknown, never as missing) |
74
- | Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" (quizzes are collected from the quiz list, the course content, and the gradebook, so a closed quiz or one the list leaves out still shows up; each course says whether its quiz sources all answered, and if Brightspace refused one it's marked incomplete rather than passed off as everything; "complete" means every source answered, not that every quiz is guaranteed to be listed. A gradebook column linked to a quiz the list omits now shows up as a quiz with its dates and attempts instead of a bare grade-only row when the quiz can be read. Gradebook-only rows now also appear on tenants that send only the documented `GradeType` on grade columns; hidden grade columns, hidden assignments and inactive quizzes never produce one. Content quizzes beyond the first 30 per course are listed from the course content alone and the course is marked incomplete. Quiz rows may also say whether they are open, closed, or not yet open, and mark a quiz whose details Brightspace would not return; gradebook lookups for unlisted quizzes are capped at 20 per course, or 5 when the course's quiz tool can't be identified) |
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 just read a file inline instead of saving it |
77
- | Syllabus | "What's the late policy in ECE 264's syllabus?" (if the course keeps its syllabus in an external tool such as Simple Syllabus, you get the link to open instead) |
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, including BoilerCast LTI links, and YouTube. When YouTube asks for a sign-in or a "not a bot" check, the answer says so, with the video link and how to get the transcript yourself: open the link in your browser, choose "Show transcript", and paste it in. The server never gets past that check for you) |
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
- | 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 get a clear "instructor access required" note for these, but can read their own released feedback, below) |
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
- ## Prompts
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
- | Prompt | Arguments | What it asks for |
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
- ## Settings you might want
88
+ Ask your assistant to save a course, or run it yourself from a terminal:
97
89
 
98
- All optional, set in your AI app's MCP `env` config or your shell. The full list is in [LLMs.md](LLMs.md).
99
-
100
- | Setting | What it does |
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 chat already shares one Brightspace server, with nothing to set up. Each chat still launches the usual command, but that command is now only a small relay: the first question from any chat starts one shared background server for this computer, and every other chat (Claude Desktop, Codex, Cursor, …) uses that same one. That means one sign-in, one saved session, and one cache for all of them. Two chats starting at the same moment still end up with one server. Closing a chat never interrupts another chat's question. After 10 minutes with no question running, the shared server stops by itself, and the next question starts it again.
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
- Chats share a server only when they use the same settings and the same version of this package. A chat started with different `D2L_*` settings, or after an update, gets its own. Because the background server isn't attached to any chat, its log goes to `runtime.log` under `~/.d2l-session/…/shared-runtime/`. Set `D2L_RUNTIME_IDLE_SECONDS` to change how long it waits before stopping.
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
- ### Running the shared server yourself (optional)
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
- You can also run **one** copy yourself and point every chat at it by URL. It uses the same saved password, saved session, and sign-in as before; a question that needs a sign-in still signs in once, for every chat.
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
- 1. **Make a secret token, once.** Chats must present it, so other programs and web pages can't read your grades. Generate one:
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
- Things that change:
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
- Any other MCP client that can connect to a Streamable HTTP URL with a bearer token can share the same server. To go back to the automatic shared server, put the `command`/`args` entry back (rerun `setup` and say yes when it offers to switch Codex back) and stop the server you started.
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 — 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 right in the answer; approve it on your phone and the assistant keeps checking on its own — each check waits up to 45 seconds and the original question completes as soon as the sign-in does, without you having to say you've approved it. If your AI client shows tool progress messages, the number appears while the question is still running instead. If it ever gets stuck, run this in a terminal:
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
- **On a Mac,** sign-in first reads your saved password from Keychain. If you picked a one-time **Allow** at the Keychain prompt, a later sign-in from your AI app or another terminal can fail or hang waiting for an approval nobody sees; pick **Always Allow** instead. That permission only lets the local Brightspace server read its own saved sign-in password, nothing else in your Keychain.
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
- **Want fewer 2FA prompts?** `setup` asks one question, "How often do you want to approve sign-ins on your phone?": **1** every time (default), **2** remember this computer, or **3** let this computer answer Microsoft's code requests itself (needs an authenticator setup key). Options 2 and 3 are opt-in and not for shared computers; to turn them off, rerun `setup` and pick 1. The tradeoffs and the steps for getting the key are in [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
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 in,** just ask your AI app to sign you in to Brightspace — it can start the sign-in itself now, and it opens a window for you when no password is saved. You can also run `npx -y brightspace-mcp-server@latest auth` in a terminal.
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
- **To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. Both also cancel a sign-in an AI app started that is still waiting for you to approve it (`auth --logout` can't on Windows, and leaves an `auth` run in another terminal alone, deleting nothing). Cancelling doesn't get you a new prompt any sooner: Microsoft won't send another until the earlier one expires, up to 5 minutes after it was sent.
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
- How often you're asked is up to your school, not this tool. Everything else about sign-in — Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) — is in [docs/sign-in.md](docs/sign-in.md).
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 — it checks your Node version, the sign-in browser, saved setup, credential store, Brightspace connectivity, saved sign-in, and installed version, and tells you exactly what to fix. For a global installation, use `brightspace-mcp-server doctor` to check the copy your client launches.
172
- - **Browser removed during disk cleanup?** Sign-in restores a missing Chromium download once and retries automatically. It uses the installed Playwright dependency's browser revision and preserves saved credentials, MFA enrollment and sessions. Downloads have a two-minute limit; offline or failed repairs return an actionable installation error instead of asking you to reset credentials.
173
- - **Repair without signing in or changing client configuration:** run `brightspace-mcp-server repair` for a global install, or `npx -y brightspace-mcp-server@latest repair` for an npx installation. Add `--json` for automation. Diagnostics print an absolute command for the exact installation being checked. Repair verifies both the full browser executable and a real headless launch and close. It does not register clients, update school settings, or start SSO/MFA.
174
- - Browser downloads are disposable; files inside an installed package's `node_modules` are dependency code. If those files were deleted, diagnostics give an installation-scoped npm command to restore the package's declared dependencies first. Browser repair does not run npm or upgrade packages automatically.
175
- - **`Authentication failed: The native credential store is locked or unavailable.`** Your password store is locked or hasn't let this server read the saved password. Unlock it (on a Mac, log in or unlock Keychain Access), then run `npx -y brightspace-mcp-server@latest auth` again, or `setup` if that still fails, and choose **Always Allow** when Keychain asks.
176
- - **It works in the terminal but not in the app:** the app starts it separately and may not see your password store yet — see [docs/troubleshooting.md](docs/troubleshooting.md).
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 environment variables: [LLMs.md](LLMs.md). What's safe to build against: [STABILITY.md](STABILITY.md). Licensed under the MIT License.
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).
@@ -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
- const pending = this.pendingGets.get(path);
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
- this.pendingGets.set(path, inFlight);
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
- return withRetry(fn, {
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.
@@ -13,17 +13,19 @@ import { AUTH_COMMAND } from "../utils/commands.js";
13
13
  import { dependencyRepairHint, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
14
14
  import { devActivity, newRecoveryId, RECOVERY_ID_ENV } from "../utils/dev-activity.js";
15
15
  import { parsePhaseMarker } from "./auth-phases.js";
16
- import { AUTOMATIC_PENDING_MARKER, MFA_HELD_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
16
+ import { AUTOMATIC_PENDING_MARKER, MFA_HELD_MARKER, MFA_MAX_WAIT_MS, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
17
17
  import { inspectLockOwner, liveAutomaticOwner } from "./auth-lock.js";
18
18
  /**
19
19
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
20
- * which is five minutes: a person has to find their phone, unlock it, and read
21
- * a number off the screen. A shorter parent budget would kill the child in the
22
- * middle of a sign-in the user was still completing. run() itself usually
20
+ * which is up to MFA_MAX_WAIT_MS (15 minutes: five for the first number and five
21
+ * more for each of the two resent ones, see purdue-sso.ts) plus the browser
22
+ * launch and sign-in pages before MFA starts. A shorter parent budget would
23
+ * kill the child in the middle of a sign-in the user was still completing
24
+ * (the 8-minute budget cut a resent number's window short). run() itself usually
23
25
  * returns long before this fires — the moment the child reports an MFA
24
26
  * challenge — but this timer keeps bounding the background child regardless.
25
27
  */
26
- const AUTH_TIMEOUT_MS = 8 * 60 * 1000;
28
+ const AUTH_TIMEOUT_MS = MFA_MAX_WAIT_MS + 3 * 60 * 1000;
27
29
  const KILL_GRACE_MS = 5000;
28
30
  /**
29
31
  * How long a caller keeps waiting for the background sign-in once the
@@ -65,8 +67,8 @@ const ABANDON_MS = MFA_POLL_MS;
65
67
  const RELAY_POLL_MS = 1000;
66
68
  /**
67
69
  * How long a relayed challenge stays "already told to a caller" for the rule
68
- * that only a RETRY waits on another process's sign-in. The MFA window is five
69
- * minutes, so a number reported longer ago than that belongs to a sign-in that
70
+ * that only a RETRY waits on another process's sign-in. One number's MFA window is five
71
+ * minutes (a resent number is a new number with a new window), so a number reported longer ago than that belongs to a sign-in that
70
72
  * is over; the same digits showing up again are a new challenge.
71
73
  */
72
74
  const RELAY_TOLD_MS = 5 * 60 * 1000;
@@ -16,6 +16,21 @@ import * as fs from "node:fs/promises";
16
16
  import { statSync } from "node:fs";
17
17
  import * as path from "node:path";
18
18
  import { CHALLENGE_FILE } from "./auth-lock.js";
19
+ /**
20
+ * How long one Microsoft number-match request stays approvable: a person has
21
+ * to find their phone, unlock it and read a prompt, and Microsoft expires the
22
+ * request itself at about this mark.
23
+ */
24
+ export const MFA_APPROVAL_WINDOW_MS = 5 * 60 * 1000;
25
+ /** Times a sign-in may ask Microsoft for a fresh number after one expires or is denied. */
26
+ export const MFA_MAX_RESENDS = 2;
27
+ /**
28
+ * The longest a sign-in waits on MFA: the first number's window plus a full
29
+ * window for each resent number (each resend restarts the clock, see
30
+ * purdue-sso.ts handleMFA). 15 minutes. Anything that bounds the sign-in
31
+ * from outside has to outlast this.
32
+ */
33
+ export const MFA_MAX_WAIT_MS = MFA_APPROVAL_WINDOW_MS * (1 + MFA_MAX_RESENDS);
19
34
  /** Same bound the auth child's stdout marker enforces: 1-3 digits and nothing else. */
20
35
  const NUMBER_MATCH = /^\d{1,3}$/;
21
36
  export function authLockPath(sessionDir) {
@@ -9,6 +9,7 @@ import { devActivity } from "../utils/dev-activity.js";
9
9
  import { AutomaticCodeAuthenticationError, MfaApprovalError, UnsupportedAuthenticationError } from "./sso-flow.js";
10
10
  import { DuoMfaHandler } from "./duo-mfa.js";
11
11
  import { AUTH_COMMAND } from "../utils/commands.js";
12
+ import { MFA_APPROVAL_WINDOW_MS, MFA_MAX_RESENDS } from "./mfa-challenge.js";
12
13
  import { generateTotp, secondsUntilFreshCode } from "./totp.js";
13
14
  // Entra names its username field type=email/loginfmt; Shibboleth portals (USC's
14
15
  // login.usc.edu among them) use the protocol's j_username.
@@ -66,16 +67,20 @@ const REMEMBER_MFA_LOG = {
66
67
  /** How often to look for the number while waiting on MFA. */
67
68
  const NUMBER_MATCH_POLL_MS = 2000;
68
69
  /** A person has to find their phone, unlock it, and read a prompt. */
69
- const MFA_TIMEOUT_MS = 5 * 60 * 1000;
70
+ const MFA_TIMEOUT_MS = MFA_APPROVAL_WINDOW_MS;
70
71
  /**
71
- * Entra's number-match request itself expires (typically ~1-2 minutes) well
72
- * before the 5-minute MFA_TIMEOUT_MS this flow allows overall, or the user
73
- * can tap Deny. Either way Entra stops showing the number and offers a way to
74
- * send another request; without asking for one this flow just polls a dead
75
- * request until MFA_TIMEOUT_MS. Bounded so a tenant that never clears this
76
- * screen still hits the ordinary timeout instead of resending forever.
72
+ * Entra's number-match request itself expires (observed live at about the
73
+ * same 5 minutes as MFA_TIMEOUT_MS, 2026-11-08; earlier tenants showed 1-2
74
+ * minutes), or the user can tap Deny. Either way Entra stops showing the
75
+ * number and offers a way to send another request; without asking for one
76
+ * this flow just polls a dead request until the deadline. Each resend gives
77
+ * the new number its own full MFA_TIMEOUT_MS (the deadline moves to
78
+ * resend time + MFA_TIMEOUT_MS), because a resend made at the 5-minute mark
79
+ * left the new number about four seconds. Bounded, so the total is at most
80
+ * (1 + MAX_RESENDS) * MFA_TIMEOUT_MS (MFA_MAX_WAIT_MS, 15 minutes) and a
81
+ * tenant that never clears this screen still ends instead of resending forever.
77
82
  */
78
- const MAX_RESENDS = 2;
83
+ const MAX_RESENDS = MFA_MAX_RESENDS;
79
84
  /**
80
85
  * EXPECTED, not verified against a live tenant from here: Entra's "Send
81
86
  * another request" control on the number-match timeout/denied view.
@@ -412,7 +417,7 @@ export class PurdueSSOFlow {
412
417
  throw new UnsupportedAuthenticationError("A school URL is required to verify authentication.");
413
418
  }
414
419
  const startedAt = Date.now();
415
- const deadline = startedAt + MFA_TIMEOUT_MS;
420
+ let deadline = startedAt + MFA_TIMEOUT_MS;
416
421
  let challenged = false;
417
422
  /** True once onMfaHeld has reported this login. */
418
423
  let heldReported = false;
@@ -565,9 +570,9 @@ export class PurdueSSOFlow {
565
570
  resendNotFoundWarned = false;
566
571
  }
567
572
  // Fix 2: Entra's number-match request itself times out (or the user
568
- // taps Deny) well before the 5-minute budget above. Once that has
573
+ // taps Deny), at the latest about when the budget above runs out. Once that has
569
574
  // happened, ask Entra for another one instead of polling a dead
570
- // request until MFA_TIMEOUT_MS; readNumberMatch on the next poll
575
+ // request until the deadline; readNumberMatch on the next poll
571
576
  // picks up the fresh number, and the re-announce logic above tells
572
577
  // the caller about it.
573
578
  if (manualRequired && sawNumber && number === null && new URL(page.url()).hostname === "login.microsoftonline.com") {
@@ -581,6 +586,8 @@ export class PurdueSSOFlow {
581
586
  onResend: (clickedAt) => {
582
587
  resendCount += 1;
583
588
  resendLastClickAt = clickedAt;
589
+ // The new number gets its own full approval window; never shorten.
590
+ deadline = Math.max(deadline, clickedAt + MFA_TIMEOUT_MS);
584
591
  },
585
592
  });
586
593
  }
@@ -637,7 +644,7 @@ export class PurdueSSOFlow {
637
644
  }
638
645
  const clickedAt = Date.now();
639
646
  state.onResend(clickedAt);
640
- log("WARN", `Number-match request expired; sent another request (${state.resendCount + 1} of ${MAX_RESENDS}).`);
647
+ log("WARN", `Number-match request expired; sent another request (${state.resendCount + 1} of ${MAX_RESENDS}). The new number gets its own ${MFA_TIMEOUT_MS / 60000} minutes.`);
641
648
  await control.click().catch(() => { });
642
649
  return state.resendNotFoundWarned;
643
650
  }
@@ -39,7 +39,7 @@ export class MfaApprovalError extends BrowserAuthError {
39
39
  */
40
40
  numberMatch;
41
41
  constructor(cause, numberMatch) {
42
- super(`MFA approval failed or timed out after 5 minutes. Run ${AUTH_COMMAND} to retry.`, "mfa_approval", cause);
42
+ super(`MFA approval failed or timed out: no approval within 5 minutes of the last number shown. Run ${AUTH_COMMAND} to retry.`, "mfa_approval", cause);
43
43
  this.name = "MfaApprovalError";
44
44
  this.numberMatch = numberMatch;
45
45
  }
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} (28 tools, 4 prompts registered)`);
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) {