brightspace-mcp-server
# Brightspace MCP Server
> Originally created by [Rohan Muppa](https://github.com/rohanmuppa) (ECE @ Purdue) as [RohanMuppa/brightspace-mcp-server](https://github.com/RohanMuppa/brightspace-mcp-server). This repository is an independently maintained continuation that adds TU Delft SSO, a Streamable HTTP transport, and a clone-based workflow.
An [MCP](https://modelcontextprotocol.io) server for D2L Brightspace. Connect it to Claude, ChatGPT, Cursor, Windsurf, or any MCP client and ask about your grades, due dates, assignments, announcements, course content, rosters, and discussions in plain language.
Works with any school on D2L Brightspace. Login is automated for Purdue (Duo MFA) and TU Delft (no MFA); other schools use the generic SSO flow or a manual browser login.
## Install
Requires [Node.js 22+](https://nodejs.org/), git, and [pnpm](https://pnpm.io/) (`corepack enable` installs the pinned version). The project runs from a clone; everything is a `pnpm run` script.
```bash
git clone https://github.com/wynn-dev/brightspace-mcp-server.git
cd brightspace-mcp-server
pnpm install # installs dependencies, downloads Chromium, builds
pnpm run setup # add --tudelft or --purdue to skip the URL prompt
```
Always use `pnpm run setup` and `pnpm run update` with the explicit `run` — bare `pnpm setup` and `pnpm update` are pnpm's own built-in commands.
The wizard stores your credentials in `~/.brightspace-mcp/config.json`, logs in once, and registers the server in Claude Desktop and Cursor if they're installed. Restart your AI client afterwards.
<details>
<summary>Other clients</summary>
Register a stdio MCP server whose command is your `node` binary and whose only argument is the absolute path to `build/index.js` in this clone. The wizard prints the exact JSON at the end:
```json
{ "command": "/usr/local/bin/node", "args": ["/home/you/brightspace-mcp-server/build/index.js"] }
```
</details>
<details>
<summary>Let an AI assistant install it</summary>
Paste into Claude Code, Cursor, Windsurf, Copilot, or Codex:
```
Install brightspace-mcp-server for me by following LLMs.md in this repo
(use --tudelft if I'm at TU Delft, --purdue if I'm at Purdue).
```
</details>
## Remote access
To reach Brightspace from an MCP client on another machine, serve MCP over Streamable HTTP instead of stdio:
```bash
MCP_AUTH_TOKEN="$(openssl rand -hex 32)" MCP_HTTP_HOST=0.0.0.0 pnpm run start:http
```
Or put those settings in `.env` / `.env.local` (see `.env.example`) and just run `pnpm run start:http`.
This exposes the 19 read-only tools at `http://<host>:8787/mcp`, including `read_course_content`. `download_file` is left out, and `get_syllabus` rejects `downloadPath` over HTTP, so course files cannot be saved to the server's disk through these tools. Clients send `Authorization: Bearer <MCP_AUTH_TOKEN>`:
```bash
claude mcp add --transport http brightspace http://your-host:8787/mcp --header "Authorization: Bearer <token>"
```
| Variable | Default | Notes |
|---|---|---|
| `MCP_HTTP_HOST` | `127.0.0.1` | Any non-loopback address **requires** `MCP_AUTH_TOKEN`. |
| `MCP_HTTP_PORT` | `8787` | |
| `MCP_AUTH_TOKEN` | — | Static bearer token; the server holds your whole Brightspace session. |
| `MCP_ALLOWED_HOSTS` | loopback names | `host:port` values accepted in the `Host` header (DNS-rebinding protection), e.g. `myserver.lan:8787`. |
| `MCP_ALLOWED_ORIGINS` | — | Browser origins to accept, if any. |
On a headless host, set `"headless": true` in `~/.brightspace-mcp/config.json` so re-login runs without a display (unattended re-login needs a school without an MFA prompt), install Chromium's system libraries on Linux with `pnpm run playwright:deps`, and keep the port behind a VPN or TLS-terminating proxy — the server itself speaks plain HTTP.
While the HTTP server is in use (a tool call within the last 12 hours), it renews the Brightspace session in the background at about 75% of the token's lifetime, so tool calls don't wait for a browser login. Renewal reuses saved cookies when they are still valid and only submits credentials if it must. After 12 hours without tool calls it stops, and the next call logs in on demand.
`GET /healthz` needs no bearer token and reports only status, timestamps and the kind of the last login failure — no usernames, messages or token data:
```json
{ "status": "ok", "version": "1.4.0", "startedAt": "…", "sessions": 1,
"auth": { "state": "valid", "expiresInMinutes": 42, "lastLogin": { "at": "…", "ok": true, "kind": null },
"retriesPaused": false, "nextRetryAt": null } }
```
`auth.state` is `valid`, `expired` (the next request logs in), or `failing` (automatic logins are paused, or the last login failed and there is no usable token). `failing` returns HTTP 503 with `"status": "degraded"`, so an uptime monitor can alert before the token runs out.
If Brightspace is unreachable at startup (maintenance, or the host booted before the network), both servers still start. They use the API versions cached in the session directory from the last successful start and keep retrying discovery in the background. Without a cache, tools report that Brightspace is unreachable until discovery succeeds.
## Checking tool discovery
Run `pnpm run diagnose` for the local stdio server, or `pnpm run diagnose --http https://your-host/mcp` for HTTP. The HTTP diagnostic reads `MCP_AUTH_TOKEN` from the environment/configured env files; do not put tokens in command arguments. Omitting the URL uses the first `MCP_ALLOWED_HOSTS` entry over HTTPS, or the local port if no host is configured. The diagnostic initializes MCP and reads `tools/list` without calling Brightspace tools. It reports the running version, expected/advertised tools, and missing names; differences produce a nonzero exit code.
Expect **20 tools over stdio and 19 over HTTP**. If tools are missing or the running version differs, run `pnpm run update` (or `pnpm run build` for local changes), then restart the server process. If discovery already contains all tools but your client shows fewer, refresh/reconnect the MCP connection or restart that client. Updating files does not replace an already running process, and clients may retain the old tool list. `check_auth` also includes the running version and tool inventory in its structured response.
## Reading course materials
The `read_course_content` tool reads uploaded **PDF, HTML, plain-text and image files** over both HTTP and stdio: course content topics, plus assignment brief, instructor feedback and announcement attachments (see [Reading attachments](#reading-attachments)). Files are fetched with your Brightspace session and processed in memory. It returns extracted text — and, for PDFs, an image of each page — not the original file, and does not update coursework or completion status.
1. Call `get_course_content` with a `courseId` (and optionally `moduleTitle` or `typeFilter: "file"`) to find a file's `topicId`. Topic descriptions in the tree are separate from uploaded file bodies; HTML pages can also appear as file topics.
2. Call `read_course_content` with those IDs. For example, to read physical PDF pages 2–4:
```json
{ "courseId": 12345, "topicId": 67890, "startPage": 2, "endPage": 4 }
```
3. If the response contains a non-null `nextCursor`, call again with the same IDs and that cursor, omitting `startPage` and `endPage`. Continue until `nextCursor` is null. The cursor preserves the selected range, is bound to the selected file, and rejects continuation if the file changes.
### Reading attachments
Files that are not content topics are read with the same tool by passing `attachment` instead of `topicId` (exactly one of the two). Decoding, page images, limits and cursors work the same way.
| `attachment.kind` | Where the IDs come from | Required fields |
|---|---|---|
| `assignment` | Assignment brief files: `attachments[].fileId` in `get_assignments` with `detail` (or `folderId`); `folderId` is the assignment `id` | `folderId`, `fileId` |
| `feedback` | Instructor feedback files on your own submission: `feedback[].files[].fileId` in `get_submission_history` (or `allFeedback` in `get_assignments` detail) | `folderId`, `fileId` (optional `entityType`/`entityId` to disambiguate) |
| `announcement` | Announcement files: `attachments[].fileId` in `get_announcements`; `newsItemId` is the announcement `id` | `newsItemId`, `fileId` |
```json
{ "courseId": 12345, "attachment": { "kind": "assignment", "folderId": 678, "fileId": 910 } }
```
Each call rechecks that the file is still listed on the assignment or announcement before fetching it; hidden assignments are refused. Feedback files are resolved only from your own released feedback (your user or group entry in your submissions); other users' or groups' feedback is never requested. Responses carry `attachment` (with the resolved `entityType`/`entityId` for feedback) instead of `topicId`, and `sourceUrl` points at the assignment or announcement page. Image, Office and other attachments outside PDF, HTML and plain text are reported as unsupported.
Responses include the document title, filename, content type, and a Brightspace `sourceUrl`. PDF `pages` entries contain physical **1-based** page numbers, text, and offsets within each page; these page numbers may differ from printed labels. HTML returns Markdown and plain-text files return decoded text, both with a text offset. Offsets and text budgets count JavaScript UTF-16 code units; continuation preserves Unicode characters. PDF pages can span multiple responses, and an empty page is reported explicitly.
PDF pages are also returned as images by default: each page that starts in the response is rendered to a JPEG (1200 px on its longest edge) and sent as an MCP image block labelled with its page number, next to the extracted text. This keeps scans, handwriting, diagrams and equations readable to the model. Pages already started in an earlier response are not rendered again. Image responses carry no `structuredContent`, because clients that prefer it would otherwise hide the images; the text payload and `readStatus` stay in the text blocks. Set `pageImages: false` for text only. If the server platform cannot load the native canvas renderer, responses fall back to text with a message.
Each response defaults to **20,000 text characters** (`maxChars`, configurable from 2 to 50,000) and at most **5 PDF pages with images** or **20 PDF pages** with `pageImages: false`, including blank pages. Files have a **50 MiB** streaming limit. Page selection is PDF-only. UTF-8, Unicode BOMs, and supported encodings declared in `Content-Type` are recognized for text files. Continuation re-fetches the document; no document cache or downloaded file is persisted.
Image files and attachments (PNG, JPEG, GIF, WebP, BMP) come back as an MCP image block plus a JSON block with the filename, dimensions and `resized` flag, without `structuredContent` (clients that prefer it would hide the image). Images up to 1568 px on the longest edge and 3.5 MB are passed through unchanged; larger ones, and BMPs, are downscaled and re-encoded as JPEG. Dimensions are read from the file header first, and images over 40 megapixels are refused before decoding. Other image types (for example HEIC or TIFF) are reported as unsupported, and page selection and cursors do not apply. Document search still skips images.
Scanned pages have no extractable text; read them from their page images (no OCR text is produced). Password-protected PDFs, Office documents, external links, videos, and learning-tool activities are unsupported. Access remains subject to your Brightspace account's permissions. Stdio clients can still save originals using `download_file` or `get_syllabus.downloadPath`.
## Student workflows
These tools work over both transports and only read Brightspace data. They do not submit coursework, change grades, join groups, check checklist items, or mark discussions as read.
Every course-scoped tool accepts `course` — a course name or code such as `"Reasoning"` or `"CSE11A"` — instead of `courseId`. Active courses are matched first (inactive ones only if nothing active matches); an ambiguous name returns the candidate courses with their IDs rather than guessing. The server also sends MCP `instructions` at initialization with a short guide to which tool answers which question.
| Tool | What it reads |
|---|---|
| `get_briefing` | One-call catch-up: work due in the next `daysAhead` days (default 7), overdue work from the last 14 days, announcements and grades released since `since` (default 7 days ago), and per-course unread discussion/feedback and unattempted-quiz counts. Each section returns `total` and the first `limit` items (default 10). |
| `get_my_work` | Assignments, quizzes, scheduled content and calendar deadlines, including overdue work. Useful for a weekly briefing. Undated assignments and quizzes are counted in `undatedExcluded`; `includeUndated: true` lists them together with undated scheduled content; `includeCalendarContext: true` adds non-deadline calendar events. Completion, exemptions and unavailable sources stay explicit. |
| `get_submission_history` | All available individual/group submissions for a `folderId`, newest first, plus released feedback, rubric assessments and attachment/link references. Feedback files can be read with `read_course_content` (`attachment.kind: "feedback"`). |
| `get_course_updates` | Batch update counts, user feed and news created or edited since `since` (default: 7 days ago); includes pinned announcements. This is an on-demand view, not a complete change log. |
| `get_grade_summary` | Visible grades, grading setup, categories, item rules and official final grade; optional in-memory what-if `scenarios` and shared class statistics (`includeStatistics`). |
| `search_course` | Case-insensitive keyword matches in announcement bodies, assignment instructions, module/topic titles and descriptions, and course descriptions. Discussions (`sources: [..., "discussions"]`) are opt-in because they need many reads. Opt into `sources: ["documents"]` for bounded PDF/HTML/text body search with PDF page references. |
| `get_my_groups` | Your course groups, sections, group descriptions, enrollment windows and linked group assignments. |
| `get_calendar` | Reminders, due dates, opening/closing events, locations and recurring occurrences, with UTC and IANA time-zone display. `start` defaults to now and `end` to 14 days after `start`. |
| `get_checklists` | Checklists, categories, item descriptions and due dates. Personal completion is **unknown** because the API does not provide it. |
Existing tools also have more focused options:
- `get_my_courses`: `query` searches names/codes; `includeDetails`, `semester` and `onDate` read course dates and semester metadata; `sort: "recent"` orders by last access. An active enrollment is not proof of the current semester. If course details are denied, dates can fall back to enrollment access metadata and semester filters can use an explicitly typed semester/term ancestor. Each field identifies its source; names/codes alone are not used to infer a term. Courses with unavailable filter metadata are retained with a null match flag. `includeProgress: true` adds each course's content `progress` (`{ completed, required }`, see `get_course_content`) for "how far am I in each course" questions, with one extra read per course (six at a time); unavailable progress is `null`, never zero.
- `get_my_grades`: with a `courseId`, `includeStatistics: true` adds the same `statistics` block as `get_grade_summary` for the listed grade items. It is refused without a `courseId`.
- `get_assignments`: returns summary rows (dates, state, submission count, score, quiz attempts) by default; `detail: true` adds instructions, rubrics, attachments, submission history and all feedback. Brief attachments (`attachments[].fileId`) and feedback files can be read with `read_course_content`. `folderId` selects one assignment (requires `courseId`) and defaults to detail; submission history comes from Brightspace's entity/group response. Quiz attempts are restricted to the authenticated user and only published scores are returned. Own-user special-access routes are checked for personal dates and quiz settings; explicit null dates remove restrictions. Denied, missing or malformed responses leave personal dates unverified. `attemptsRemaining` stays null because a reliable remaining-attempt count requires more than an override. `attemptsRemainingAssumingDefault` is explicitly conditional.
- `get_syllabus` and `get_roster`: if their primary source is unavailable, `includeContentFallback` (default true) finds up to three accessible course-guide or staff/contact documents. Excerpts are separate candidates with source links and PDF page references; they do not establish an official syllabus or staff membership. Set the option to false to skip those reads.
- `get_discussions`: supply `forumId` and `topicId`, then optionally `threadId`, `unreadOnly`, `ownOnly` (posts authored by you), `since`, `threadsOnly`, `sort`, `pageNumber` and `pageSize`. Forum-only calls list topics; request a topic to read posts. Forum/topic lists also use `pageNumber` and `pageSize`; follow `nextPage`, or select a forum and follow its `nextTopicPage`.
- `get_my_grades`: each course includes its released `finalGrade` (`{ displayGrade, points, maxPoints }`) or null with `finalGradeStatus` (e.g. `not_found` before release), next to the grade items. Use `get_grade_summary` for grading rules and what-if projections.
- `get_roster`: institution-provided role names replace Purdue-only role IDs. Default staff selection uses a name heuristic and exposes available/unrecognized roles. Use `roleNames` for exact institution labels or `includeStudents` for everyone. Denied classlist access is reported as unavailable.
- `get_course_content`: returns a compact outline built from the course table of contents in a single request. Modules are `{ type: "module", moduleId, title, progress?, description?, children }`; topics are `{ type: "file" | "link" | ..., topicId, title, dueDate?, isCompleted, completedDate?, unread?, description?, url? }`, ordered as in Brightspace. Flags that are false (`unread`, `isHidden`, `isLocked`) and absent dates are omitted. Descriptions are cut to a 160-character snippet ending in "…" unless `includeDescriptions: true`. Due dates and completion come from the scheduled-content API; unlisted or optional items have null completion, rather than being reported as unread/incomplete. Reading file text does not update completion. The top-level `progress` and each top-level module's `progress` are `{ completed, required }` topic counts from Brightspace's own completion tracking (`/content/completions/mycount/`, LE 1.82+). They cover **required** topics only: optional topics are not in `required`, and Brightspace documents that `completed` can also include completed optional or hidden topics. If Brightspace denies or cannot return the counts, `progress` is `null` (or omitted on modules) and the read is listed in `readStatus`; it is never reported as zero. `includeProgress: false` skips these two reads.
### Examples
A work overview for the next two weeks and the preceding week's overdue items:
```json
{ "courseId": 12345, "daysAhead": 14, "daysBehind": 7, "includeUndated": true }
```
Pass that to `get_my_work`. Submitted assignments and completed quiz attempts are omitted by default, though they may still allow further submissions; use `includeCompleted: true` to include them. Closing dates are separate from due dates. Assignment and quiz dates use verified own-user special access when permitted; an active own-user quiz attempt can supply its current attempt deadline. Otherwise dates remain labelled course defaults, with `overdue` and `closed` unknown and `overdueAssumingCourseDefaults` explicitly conditional. Each work item's `datesSource` is `special_access` (verified personal dates), `course_defaults` (extensions unverified) or `active_attempt`; scheduled content and calendar items are identified by `source`. Undated work is left out by default; undated assignments and quizzes are counted in `undatedExcluded`. Dated scheduled content is read for all courses in one window-filtered request. If unverified default dates exclude work from the requested window, `unverifiedDatesOutsideWindow` and a read-status limit warn that extensions could move it into the window; inspect `get_assignments` without a date window. Calendar context (non-deadline events) is only included with `includeCalendarContext: true` and is capped at the requested limit; use `get_calendar` to continue. Sources can describe the same activity, so source identities are preserved instead of guessing which entries to merge.
A calendar view with recurring occurrences and local display, passed to `get_calendar`:
```json
{ "courseId": 12345, "start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z", "timeZone": "Europe/Amsterdam" }
```
The window is start-inclusive/end-exclusive and limited to 366 days. All-day `endDayExclusive` values are preserved as dates, without interpreting them as midnight UTC. If the recurrence route is unavailable, the tool tries ordinary events and reports that occurrences were not expanded. Event types require LE 1.94 or newer; older responses may have an unknown type.
A hypothetical score, passed to `get_grade_summary`:
```json
{ "courseId": 12345, "scenarios": [{ "gradeItemId": 67890, "points": 8 }] }
```
Calculations currently support uncategorized numeric **Points** and **Weighted** gradebooks with verified item rules and personal exemption data. Verified ungraded items follow the returned course rule; missing does not automatically mean zero. If an unavailable score could instead be unreleased, the calculation requires an explicit scenario score. Formula grades, categories/drop rules, bonus/extra-credit rules, uncertain exemptions, invalid weights and incomplete sources return `calculation.status: "unsupported"` with reasons. The grade explanation still works when calculations are unavailable. Projections concern the grade objects returned to your account; they cannot infer hidden/unreleased items and are separate from `officialFinal`. No GPA or institution-wide degree rules are inferred.
Class statistics, for "how did the class do on the midterm", passed to `get_grade_summary` (or `get_my_grades` with a `courseId`):
```json
{ "courseId": 12345, "includeStatistics": true }
```
This reads `/grades/{gradeItemId}/statistics` for each non-text grade item (four at a time, up to 100 items) and returns `statistics: { status, items, unavailable, notChecked, note? }`. `items` holds `{ gradeItemId, minimum, maximum, average, median, mode, standardDeviation }`, rounded to two decimals on the scale Brightspace reports. Brightspace only returns statistics that the instructor shares. A 403 means **not shared with you**, not missing data: the first item is probed alone, and probing stops after the first 403 in a course. The remaining items are listed in `notChecked`, not reported as unshared. `status` is `available`, `partial`, `not_shared`, `unavailable` (404 or malformed responses, listed in `unavailable` and `readStatus`) or `no_items`. Statistics never affect the grade calculation.
### Pagination and reliability
Most new list tools accept `offset` (default 0) and `limit` (default 25, maximum 100), returning `items`, `nextOffset` and `matchedCount`. `get_assignments` keeps its `assignments` array and paginates per course. `get_roster` now returns a paginated object instead of a bare array. Follow continuation using the same filters. Offsets describe the current fetched results, not a durable snapshot; data can change between calls.
Discussion filters apply to the requested API page by default. Set `maxPagesToScan` (1–10, default 1) to continue automatically past sparse pages until a page-size worth of matches is found, the scan limit is reached, or the source ends. All matches on the final scanned page are retained, so the response can exceed `pageSize`. Inspect `pagesScanned`, `nextPage` and `exhausted`; an empty result is not proof of exhaustion. A full final API page can require one extra call to establish the end. Search scans up to 10 forums, 10 topics per forum and the first 100 posts per topic per course; use focused discussion pagination to continue. Document bodies are searched only when `sources` includes `"documents"`. This reads up to `maxDocuments` files (default 3, maximum 10) and `maxDocumentChars` characters per file (default 20,000, maximum 50,000), with a 20-page PDF cap. Use `documentTopicIds` to focus the scan and `nextDocumentOffset` as the next `documentOffset` to scan more files. Normal `offset`/`nextOffset` paginate matches within that same scan; exhaust those matches before advancing the document offset. Truncated excerpts and inaccessible/unsupported files remain explicit in `coverage` and `readStatus`; use `read_course_content` to read the rest of a document. An empty search result does not establish absence from unscanned text. No index is stored.
Data tools append `readStatus` in an additional text block and in `structuredContent`, preserving the main payload in the first text block. `structuredContent` carries the same payload as that first text block (arrays are wrapped as `{ "items": [...] }`) plus `readStatus`, because clients that support structured results, such as claude.ai, show it instead of the text. Errors carry `{ "error": message }`. Payloads are compact JSON. `readStatus` reports the number of API `reads`, `cachedReads` and `oldestCachedAt` when cached data was used, the `failed` sources with their status (`forbidden`, `not_found`, `error`), `limits`, and a `partial` flag; successful sources are counted rather than listed. Inspect it before treating empty results as “nothing to do.” A 404 can mean an absent feature or inaccessible item, not necessarily an empty dataset. Partial page chains keep previously fetched items, including when a later response is malformed. Grade projections stop when required sources are incomplete or contain duplicate grade values or exemptions. A quiz with an ongoing attempt remains in the work overview even after an earlier completed attempt.
Concurrent expired-session reads share token invalidation and automatic login, then retry once. On Linux hosts without a display, automatic login with stored credentials uses a headless browser even if the saved desktop configuration requests a visible one. Manual login still requires a display. `get_syllabus` also preserves plain-text overviews and recognizes PDF attachments without a `.pdf` filename; an inaccessible attachment has unknown presence (`hasAttachment: null`).
New service reads use a 60-second in-memory cache and normally cap each source at 500 items/10 API pages; individual tools have additional limits recorded in `readStatus.limits`. The work overview inspects at most 100 assignments/quizzes per course; use assignment pagination for the rest. Aggregate calls stop after 200 network reads (cache hits do not count); narrow course/source filters if the response is partial. Brightspace requests are throttled client-side to bursts of 20 and 10 per second; an HTTP 429 is retried once after `Retry-After` (up to 10 seconds). The work overview reads courses in parallel and only reads submission status for assignments whose effective due date falls in the window (or undated ones, with `includeUndated`), so `unverifiedDatesOutsideWindow` can include already-completed work. See [the coursework coverage review](docs/coursework-coverage-review.md) for addressed limitations and [API contract notes](docs/read-only-api-notes.md) for endpoint details and validation boundaries. No persistent document index, snapshots or alert jobs are created. Awards, competency maps, external systems and outbound alerts remain outside this scope.
## Commands
| Command | What it does |
|---|---|
| `pnpm run setup` | Setup wizard (`-- --purdue`, `-- --tudelft`) |
| `pnpm run auth` | Log in again if automatic re-auth fails |
| `pnpm run start` | Stdio MCP server (what your AI client runs) |
| `pnpm run start:http` | Streamable HTTP MCP server |
| `pnpm run diagnose` | Check MCP version and tool discovery; add `--http [url]` for an HTTP server |
| `pnpm run update` | Pull the latest code, reinstall, rebuild |
| `pnpm run build` / `pnpm run dev` | Compile once / watch |
| `pnpm test` | Run the test suite |
| `pnpm run playwright:deps` | Install Chromium system libraries (Linux) |
When a Brightspace request finds an expired session, it re-authenticates automatically and retries. Concurrent requests wait for the same login attempt. This uses the configured school login flow and stored credentials; required MFA or manual browser steps still need interaction. Run `pnpm run auth` only if automatic login fails. Authentication may refresh local session files; the read-only tools do not modify coursework or save course documents over HTTP.
Session expiry comes from the token itself (Brightspace's `expires_at`, or the token's JWT `exp` claim). `D2L_TOKEN_TTL` is only a fallback. Saved cookies are reused for as long as their own expiry allows, and each restored session is checked against Brightspace before use.
Failed automatic logins trip a circuit breaker, which is shared by every server process through `auth-state.json` in the session directory and survives restarts:
- **Password rejected:** automatic logins stop, so the server can't lock your account by resubmitting bad credentials. They resume when the stored credentials or `config.json` change (for example after `pnpm run setup`), or after a successful manual `pnpm run auth`.
- **Anything else** (timeout, MFA or other interaction required, unexpected SSO page, crash): retries back off to 1, 5, 15, then 60 minutes.
While the breaker is open, tool calls fail immediately without launching a browser. The error says why and when the next automatic retry happens, e.g. *"Brightspace login failed: username or password rejected. Update credentials on the server (pnpm run setup) — automatic retries paused."* `check_auth` reports the same stored status and does not start a login while retries are paused. A manual `pnpm run auth` always tries.
Only one browser login runs at a time across all processes that share the session directory. They coordinate through a `login.lock` file, which is taken over if its owner died or it is older than 10 minutes. A process waiting on that lock reuses the session the other process saved. A process that gets a 401 re-reads the session file first, so it won't delete a newer session another process just saved.
## Configuration
Set in `~/.brightspace-mcp/config.json` (written by the wizard), or as environment variables — either in your shell or in a `.env` / `.env.local` file in the project root (copy `.env.example`). Precedence is shell > `.env.local` > `.env` > `config.json`.
| Variable | Default | Purpose |
|---|---|---|
| `D2L_BASE_URL` | — | Your Brightspace URL; also selects the login flow |
| `D2L_USERNAME` / `D2L_PASSWORD` | — | Credentials for automated login; omit for a manual browser login |
| `D2L_HEADLESS` | `false` | Hide the browser during login |
| `D2L_SESSION_DIR` | `~/.d2l-session` | Where the encrypted token, cookies, login state (`auth-state.json`) and cached API versions live |
| `D2L_TOKEN_TTL` | `3600` | Fallback token lifetime in seconds, used only when the token carries no expiry |
| `D2L_INCLUDE_COURSES` / `D2L_EXCLUDE_COURSES` | — | Comma-separated course IDs to filter |
| `D2L_ACTIVE_ONLY` | `true` | Hide inactive courses |
| `D2L_LOG_LEVEL` | `INFO` | Log verbosity on stderr: `DEBUG`, `INFO`, `WARN`, `ERROR` |
## Security
- Credentials stay on your machine in `~/.brightspace-mcp/config.json` (mode 0600)
- Session tokens are encrypted at rest (AES-256-GCM)
- All traffic to Brightspace is HTTPS; nothing is sent anywhere except your school's login page
- The HTTP server refuses to bind a non-loopback address without a bearer token
## Contributing
### Paseo workspaces
The repo includes [`paseo.json`](paseo.json) for [Paseo worktrees](https://paseo.sh/docs/worktrees). Install Node.js 22+ and enable pnpm with `corepack enable` on the Paseo host first. New worktrees first copy `.env` and `.env.local` from `PASEO_SOURCE_CHECKOUT_PATH` when present, preserving any existing worktree files. Setup then runs `pnpm install --frozen-lockfile`, which downloads Chromium and builds the server through the existing install hooks.
Paseo also offers `build`, `test` (a single test run), and `dev` (TypeScript watch) scripts. For example, run `paseo script start test` from the workspace directory.
Brightspace login remains a manual step: run `pnpm run setup` when needed. Worktrees on the same host share the home-directory config and session stores. Linux hosts that need Chromium system libraries can run `pnpm run playwright:deps` once.
Paseo reads this config from the committed base branch, so commit it to the branch used to create new worktrees before expecting automatic setup.
**Add your school:** add a preset to `SCHOOL_PRESETS` in `src/setup.ts`. If its login flow differs, add an `SSOFlow` in `src/auth/` (see `tudelft-sso.ts`) and select it in `BrowserAuth`.
**Add a tool:** create a file in `src/tools/`, add its schema to `schemas.ts`, export it from `src/tools/index.ts`, and register it in `createMcpServer()` in `src/server.ts`. Mark read-only tools with `annotations: { readOnlyHint: true }`.
`LLMs.md` has a codebase map for contributors and AI assistants.
## License
MIT License · Copyright 2026 Rohan Muppa and contributors · [Report a bug](https://github.com/wynn-dev/brightspace-mcp-server/issues)
TDQS
Scored across 12 tools
Most tools target clearly distinct resources, but get_classlist_emails and get_roster overlap significantly since both return course participant contact info. get_assignments and get_upcoming_due_dates also both surface due dates, though their descriptions help differentiate them.
Tool names consistently follow a snake_case verb_noun pattern, with get_ prefixed retrieval tools covering most of the surface. check_auth and download_file deviate from get_ but still use clear action_noun naming, so the overall pattern remains predictable.
Twelve tools is well within the ideal range for an LMS integration server. Each tool covers a meaningful student-facing Brightspace capability, from auth and courses to grades, content, files, roster, syllabus, and discussions.
The tool surface is comprehensive for a read-only Brightspace assistant: it covers courses, due dates, grades, announcements, assignments, content, file downloads, rosters, syllabus, and discussions. No critical dead ends are apparent, and agents can chain these tools to answer a wide range of student queries.