ntulearn-mcp
README.md
<p align="center">
<img src="assets/banner.webp" alt="ntulearn-mcp — your NTULearn courses, announcements and grades, inside your agent" width="100%">
</p>
# ntulearn-mcp
**MCP server + agent-first CLI for NTULearn** — NTU Singapore's Blackboard Learn instance.
Point any MCP host (Claude Desktop, Claude Code, Cursor, Cline, Prime Agent) at it to ask about your
courses, announcements, calendar, and grades — and to organise course files into a folder hierarchy
on disk.
The shipped server is a **single small Rust binary** with a built-in minimal MCP stdio
implementation (no heavyweight framework — see [the dependency diet](#a-single-small-binary)),
22 tools, 2 prompt templates, and a course-resource URI. No Python, no pip, no virtualenv.
**Website:** <https://ntulearn.krtk.app>
---
## Quick start
```bash
git clone https://github.com/gangula-karthik/ntulearn-mcp.git
cd ntulearn-mcp/rust
cargo build --release # binary: rust/target/release/ntulearn-mcp
```
Log in once — `setup` opens NTULearn in a dedicated browser window and captures your session for
you. No copy-paste, no devtools, no keychain/password prompts. After that the server renews the
session silently in the background. (No terminal? Ask your agent to call `ntulearn_login`.)
```bash
rust/target/release/ntulearn-mcp setup
rust/target/release/ntulearn-mcp check # expect: Live validity : OK (200)
```
Register the binary with your MCP host:
```json
{
"mcpServers": {
"ntulearn": {
"command": "/Users/you/ntulearn-mcp/rust/target/release/ntulearn-mcp",
"args": []
}
}
}
```
That's it. The same binary serves MCP with no arguments and acts as a CLI with a command.
---
## Why the CLI: about 64x fewer tokens
MCP hosts inject every tool's JSON schema into context **every turn** — about 6.6k tokens for this
server's 21 tools. The same binary doubles as a plain CLI, whose terse tabular output an agent reads
in a few hundred tokens:
```bash
ntulearn-mcp courses # 6 courses ≈ 100 tokens (MCP tool result: ≈ 450)
ntulearn-mcp tree _2706844_1 # a course's content tree
ntulearn-mcp whats-new --days 7 # announcements + due dates + grades digest
ntulearn-mcp multi "courses" "grades" "upcoming --type GradebookColumn" # concurrent
```
Measured with the built-in benchmark (`ntulearn-mcp selfbench`):
| Metric | MCP | CLI | Improvement |
|---|---|---|---|
| Discovery tokens (per conversation) | 7,155 (every turn) | 633 (once, on demand) | **11.3x** |
| Task output tokens (4-task suite) | 2,193 | 492 (compact TSV) | **4.5x** |
| 10-turn conversation model | ~73,743 tok | ~1,125 tok | **65.5x** |
Latency (mock API, 25 ms RTT, cold cache): all-courses tools fetch every enrolled course
**concurrently** (bounded at 48 in flight) instead of one-by-one. `announcements` across 6 courses
drops from 214 ms to 60 ms, the `whats_new` digest from 703 ms to 65 ms, and a 44-course `whats_new`
completes in ~0.10 s. Content trees walk one level per wave: a 403-node tree (13 weeks x 5 modules x
5 files) drops from 2.3 s to 0.13 s. Multi-file downloads and attachment URL resolution run in parallel too.
| Latency check | Before | Now | Gain |
|---|---|---|---|
| `courses` cold (2 RTTs, 25ms mock) | - | 58ms | **3.4x** vs v0.3 |
| `courses` warm (SQLite cache) | - | 57µs | - |
| 4 commands concurrently (`multi`) | 225ms sequential | 60ms (singleflight-deduped) | **3.8x** |
The CLI shares the same handlers, cache, and cookie lifecycle as the MCP server. Point your agent at
the binary and have it run `ntulearn-mcp help` (≈590 tokens, once) instead of registering 21 tools.
---
## What people ask for
1. **"What announcements happened across my courses this week?"** — fans out across all enrolled courses, newest first.
2. **"What assignments are due next week?"** — reads the calendar, including gradable items.
3. **"Organise this semester's content into `~/NTU/y3s1/sc2002/week 8/…`."** — walks the course tree and downloads into any folder layout you describe.
4. **"Pull the due dates and grade weightages out of this course briefing PDF."** — reads small text-heavy documents inline.
For multi-page, diagram-heavy lecture decks, use `download_file` and open the PDF in your client —
MCP tool results are capped at 1 MB, so this server does not compete with drag-and-drop for full decks.
---
## CLI reference
`ntulearn-mcp help` prints this list; `ntulearn-mcp help <cmd>` shows one command's flags.
```
courses my enrolled courses (run this first for course ids)
contents course-id one level of a course's content tree
tree course-id entire content tree of a course in one call
search course-id query search one course's content tree
find query search content across ALL courses
dl course-id content-id download a content item's files to disk
read course-id content-id read a content item's files inline (pdf/office/text)
upcoming calendar items + due dates across courses
announcements announcements across courses, newest first
grades gradebook columns + your grades across courses
attempts course-id column-id attempts on one gradebook column
messages course messages (inbox/sent)
message message-id read one course message
users course-id users enrolled in a course
groups course-id groups in a course
members course-id group-id members of one group
summary course-id one-shot course digest (staff, contents, dates)
whats-new curated digest of everything new since a cutoff
dl-course course-id bulk-download a course's files
ics export calendar items to an .ics file
grades-csv export gradebook to .csv
```
Output is **compact TSV** by default. Flags: `--json`, `--md`, `--toon`, `--fields a,b`,
`--max-chars N`, `--full`, `--limit N`, `--offset N`. Every MCP tool argument is also accepted as a
kebab-case flag (`--include-disabled`, `--course-ids a,b`, `--mode vision`).
`multi` runs several commands concurrently in one process, deduplicating identical in-flight HTTP
fetches and sharing one cache:
```bash
ntulearn-mcp multi "courses" "announcements --since 2026-01-01" "grades"
# results arrive concatenated, each prefixed with: ===== <command> =====
```
### Output formats
| Format | Flag | Notes |
|---|---|---|
| Compact TSV | default | Smallest; field-capped, no nesting. |
| Markdown | `--md` | Human-readable. |
| JSON | `--json` | Raw payload. |
| TOON | `--toon` | Smallest **full-fidelity** format — **40% fewer tokens** than pretty JSON (52% on large lists). |
TOON ([Token-Oriented Object Notation](https://github.com/toon-format/toon), spec v4.1) declares
array shapes once and drops repeated keys, so the savings scale with how tabular the payload is.
Every MCP tool accepts `response_format: "json"` or `"markdown"` too — and as of v0.7.2
**TOON is the default for MCP tool responses** (the CLI keeps its compact-TSV default).
---
## Tools
21 tools, most doing cross-course aggregation by default — you rarely pass course IDs by hand.
| Tool | What it does |
|---|---|
| `ntulearn_list_courses` | List enrolled courses. |
| `ntulearn_get_course_contents` | Walk a course's content tree. Omit `parent_id` for the top level; pass it to drill into a folder. |
| `ntulearn_search_course_content` | Recursive substring search within one course. |
| `ntulearn_get_upcoming` | Calendar items across enrolled courses. Defaults to the next 2 weeks. `type='GradebookColumn'` filters to assignments. |
| `ntulearn_get_announcements` | Announcements across enrolled courses, newest first. Optional `since`. |
| `ntulearn_get_gradebook` | Gradebook columns across enrolled courses, with your scores when available. |
| `ntulearn_download_file` | Download every file on a content item to disk. `destination_dir` builds hierarchies. |
| `ntulearn_read_file_content` | Read an attached file's content inline (no filesystem hop). |
| `ntulearn_list_messages` | List mailbox messages (inbox/sent). |
| `ntulearn_read_message` | Read one message by ID, with full body and recipients. |
| `ntulearn_list_course_users` | List users in a course (instructors, TAs, students). |
| `ntulearn_list_course_groups` | List the groups defined in a course. |
| `ntulearn_get_group_members` | List the members of a course group. |
| `ntulearn_get_gradebook_attempts` | List submission attempts for an assignment. |
| `ntulearn_search_all_courses` | Search content across **all** courses; results carry courseId + breadcrumb. |
| `ntulearn_get_content_tree` | One course's entire content tree as nested JSON (bounded by `max_depth`). |
| `ntulearn_download_course` | Recursively download every file in a course to `~/Downloads/NTU/<course>`. |
| `ntulearn_whats_new` | One-call digest: announcements + upcoming + gradebook since a cutoff. |
| `ntulearn_export_calendar_ics` | Export calendar items as an iCalendar `.ics` string. |
| `ntulearn_export_gradebook_csv` | Export your gradebook as a CSV string. |
| `ntulearn_summarize_course` | Briefing for one course: instructors, upcoming, announcements, grades, top folders. |
Read-only tools default to `response_format='toon'` (token-optimized); pass `'json'` for structured
data or `'markdown'` for a readable summary.
List-returning tools accept `limit`/`offset`. A resource URI,
`ntulearn://courses/{course_id}`, returns a JSON course briefing, and two prompts
(`ntulearn-weekly-brief`, `ntulearn-assignment-triage`) chain the tools for you.
### Known environment limits
- **`list_messages` / `read_message`** — the public `/users/me/messages` REST API returns 404 on this
instance, so the client walks the internal v1 conversation API instead (one cached mailbox flatten
→ per-course conversations) and derives recipients from the conversation.
- **`get_group_members`** — the public groups endpoint returns 403 for student accounts, so the
client uses the internal v1 memberships endpoint instead.
- **Calendar windows wider than ~16 weeks** are rejected by NTULearn with a 400 `courseErrors` entry
— keep `since`/`until` within a semester.
- **`read_file_content`** extracts text from simple documents; for large or graphical PDFs, use
`download_file` and open the file in your client.
---
## Authentication
The `BbRouter` cookie is resolved strictly in this order:
1. `NTULEARN_COOKIE` environment variable
2. Config file at `<config>/ntulearn-mcp/cookie`
3. Firefox `cookies.sqlite` (read-only, plaintext)
**No keychain access, ever** — no password dialogs, no `security` commands. The capture browser is
launched with `--use-mock-keychain --password-store=basic`, so it never asks macOS Keychain, GNOME
Keyring or KWallet for anything.
`setup` (or the `ntulearn_login` tool) is the first-run path. It works with **any** Chromium-based
browser you have installed — Chrome, Edge, Brave, Arc, Vivaldi, Opera, Chromium — regardless of
which browser you normally use, and never touches your real browser profile. It opens NTULearn in a
dedicated profile (`<config>/ntulearn-mcp/capture-profile-v2`), waits for you to log in, validates
the cookie live, and saves it. Answer **Yes** to "Stay signed in?".
After that, sessions renew themselves:
- NTULearn cookies last ~3 h. Before one expires — or when a call hits 401 — the server re-logs in
through the saved NTU SSO session in a **headless** browser: no window, no password.
- SSO session cookies are re-saved with a 30-day lifetime in the dedicated profile, so the silent
re-login keeps working across browser and server restarts (NTU's IdP still enforces its own limit).
- When even the SSO session has ended, the login window opens **by itself** in the background (only
on machines that have logged in through the capture flow before — a fresh install never spawns a
window unasked). The failing tool returns "a browser login window has just opened"; log in there,
and a retry of the same request succeeds — the fresh session is hot-swapped in the moment it lands.
At most one window is ever open, no matter how many tools fail while you are away.
- Browsers never pile up: capture is single-flight **machine-wide** (a cross-process lock covers
multiple MCP hosts and the CLI at once), orphaned capture browsers from a killed server are
reaped automatically, an unattended login window is not re-opened for 30 min, and when the SSO
session is dead all silent sign-in retries back off for 6 h instead of launching headless
browsers that cannot succeed.
Each laptop needs its own one-time login. With no Chromium browser at all (or set
`NTULEARN_BROWSER` to a binary path), `setup` falls back to a one-time paste.
<details>
<summary>Manual cookie fallback</summary>
1. Log in at <https://ntulearn.ntu.edu.sg>.
2. DevTools (`F12`) → **Application** → **Cookies** → `ntulearn.ntu.edu.sg`.
3. Copy the **Value** of the `BbRouter` cookie (starts with `expires:`).
4. Run `ntulearn-mcp setup` and choose paste, or set it in your MCP host's `env` block:
```json
{ "env": { "NTULEARN_COOKIE": "expires:1234567890,id:..." } }
```
5. Restart your MCP host.
</details>
---
## Configuration
All optional — the defaults work for most users.
| Env var | Default | Purpose |
|---|---|---|
| `NTULEARN_COOKIE` | — | Manual cookie fallback. |
| `NTULEARN_BROWSER` | auto-detect | Path to a Chromium-based browser binary for login capture. |
| `NTULEARN_BASE_URL` | `https://ntulearn.ntu.edu.sg` | Change for a different Blackboard instance. |
| `NTULEARN_DOWNLOAD_DIR` | `./downloads` | Default `destination_dir` for `download_file` / `download_course`. |
| `NTULEARN_CACHE_DIR` | `~/.cache/ntulearn-mcp/cache.sqlite3` | SQLite response cache (falls back to in-memory). |
| `NTULEARN_CACHE_MODE` | `readwrite` | `readwrite`, `readonly`, or `off`. |
| `NTULEARN_HTTP_CONCURRENCY` | `48` | Max in-flight HTTP requests. Lower it if a tenant rate-limits; raise it for large course lists. |
| `NTULEARN_MCP_AUTOUPDATE` | `on` | `on`, `off`, or `ask`. |
| `NTULEARN_ISSUE_REPO` | upstream repo | Where unexpected-error issues are filed. |
| `NTULEARN_ISSUE_REPORTS` | — | Set to `off` to force-disable issue auto-filing. |
| `NTULEARN_MCP_NO_UPDATE_CHECK` | — | Set to `1` to disable update checks. |
| `NTULEARN_MCP_UPDATE_INTERVAL_SECS` | 86400 | Check throttle. |
| `NTULEARN_MCP_REPO` | upstream repo | Override for forks. |
| `NTULEARN_MCP_REQUIRE_SIGNATURE` | — | Set to `1` to refuse unsigned releases. |
---
## Updates
New releases **auto-install by default**: the binary checks GitHub at most once every 24 hours, and
when a newer release exists it is downloaded, signature-verified, and atomically swapped in — the
new version activates when your MCP host next restarts the server. The first interactive
`setup`/`update` run asks once whether you want this (answer "no" to switch to notify-only), and
`update --auto off` changes it any time. A one-line notice still appears when an update lands.
```bash
ntulearn-mcp update # check + prompt before installing
ntulearn-mcp update --check # exit 0 = current, 10 = update available
ntulearn-mcp update --yes # install without prompting
ntulearn-mcp update --auto on # background-install new releases (default)
ntulearn-mcp update --channel beta # try prereleases first
```
```bash
ntulearn-mcp update # check + prompt before installing
ntulearn-mcp update --check # exit 0 = current, 10 = update available
ntulearn-mcp update --yes # install without prompting
ntulearn-mcp update --auto on # background-install new releases
ntulearn-mcp update --channel beta # try prereleases first
```
With `--auto on` the server downloads the release in the background, verifies its **sha256 against
the release's `checksums.txt`**, and atomically swaps the binary — the new version activates when
your MCP host next restarts the server. Every `checksums.txt` is **minisign-signed** by CI, and the
public key is committed as [`minisign.pub`](minisign.pub) and baked into the binary, so a tampered
release is refused. Set `require_signature: true` in `settings.json` to refuse unsigned releases outright. Installs the binary did not make itself (cargo, Homebrew, dev builds) are
detected and answered with the right update command instead of a self-replace. A Homebrew formula
template lives in [`packaging/homebrew/`](packaging/homebrew/).
---
## Error reporting (opt-in)
The server can **auto-file a GitHub issue** when it keeps hitting the same *unexpected* error — the
kind that would otherwise only surface as a dead end in some agent's transcript. This is opt-in:
you are asked once during `setup`/`update`, or toggle it directly:
```bash
ntulearn-mcp issues on # opt in
ntulearn-mcp issues off # opt out
ntulearn-mcp issues # status: enabled, gh availability, filed count
```
Behavior:
- **Only unexpected errors.** Auth/session expiry, bad arguments, and transient network failures
self-heal or are the caller's fault — they are never filed. Parse failures, resolution dead ends
("No download URL found"), and panics are.
- **Once per error per version.** Errors are normalized to a signature; a signature is filed at
most once per binary version (an existing open issue with the same title is detected across
machines too).
- **Public-repo safe.** Course/content IDs, emails, cookie values, and long tokens are redacted
before anything leaves the machine.
- **Needs `gh`.** Filed through the GitHub CLI using your local `gh` auth; no-op when absent.
`NTULEARN_ISSUE_REPO` retargets (useful for forks), `NTULEARN_ISSUE_REPORTS=off` force-disables.
**Cutting a release** (maintainers): bump `rust/Cargo.toml`, tag `v<version>`, push. The workflow
builds Linux, macOS (arm64 + x64), and Windows binaries, and publishes `checksums.txt` with its
minisign signature. Tags with a hyphen (e.g. `v0.8.0-rc.1`) publish as prereleases automatically.
---
## Troubleshooting
- **"Not logged in to NTULearn", or tools fail with 401** — ask your agent to call `ntulearn_login`,
or run `ntulearn-mcp setup`. `ntulearn-mcp check` shows the cookie source and live validity.
- **A macOS "wants to use your confidential information" prompt** — that came from versions before
the mock-keychain fix; update, click Deny, and log in once more with `setup`.
- **MCP host lists the server but tool calls return nothing** — run
`rust/target/release/ntulearn-mcp` directly. The usual cause is an unresolvable cookie; the error
message will say so.
- **Prime Agent lists the server but tools aren't available** — run `/reload` (or restart it) so
settings are re-read.
- **`read_file_content` returns "No download URL found"** — for `x-bb-document` pages this is fixed
(the page body's embedded file links are now resolved). If it still happens, the content node is
likely not a file at all (a text page or tool link) — and with `issues on` the server files a
report automatically.
---
## A single small binary
The server has no heavyweight framework behind it: the MCP stdio transport is
~450 lines of in-tree JSON-RPC (`src/mcp.rs`) over tokio, reqwest does the REST
calls (rustls, gzip only), and rusqlite backs the TTL cache and Firefox cookie
reads. As of v0.7.2:
| Metric | v0.7.1 | v0.7.2 |
|---|---|---|
| Crates compiled | 383 | **277** |
| Release binary | 16.4 MB | **5.7 MB** |
| Cold `cargo build --release` | minutes (brotli + ring C toolchains in the graph) | **~64 s** on an M-series laptop |
`selfbench` confirms zero runtime regression: warm dispatch ~0.1 ms, cold
mock-RTT numbers unchanged (see "Why the CLI" above).
## Development
```bash
cd rust
cargo test # 111 unit tests
cargo build --release
target/release/ntulearn-mcp selfbench # token + latency + concurrency benchmark
```
Layout (`rust/crates/ntulearn-mcp/src/`):
```
main.rs no args → MCP stdio server; command → CLI; setup/check/refresh
cli.rs the 21 commands, compact TSV renderer, `multi` concurrency
handlers.rs the 21 tool handlers (shared by MCP server and CLI)
bench.rs selfbench + mock Blackboard API
client.rs reqwest (HTTP/2, retries, parallel fan-out, singleflight dedup)
cache.rs SQLite (WAL, persistent conn) + in-memory TTL cache
cookie.rs layered cookie resolution (never keychain)
setup.rs setup / check / refresh subcommands
capture.rs throwaway-browser CDP cookie capture
parsers.rs HTML body → download URL extraction
render.rs markdown / csv / ics renderers
resources.rs course resource template + reader
prompts.rs prompt templates
schemas.rs tool schemas
tools.rs tool definition registry
```
Before pushing, check that no credential is staged or in history:
```bash
git status --short
git log --all -p | grep -nE '(BbRouter=|expires:[0-9]{10,},id:|Set-Cookie|ghp_[A-Za-z0-9]{20,})' | grep -viE 'test|example|README' || echo "clean"
```
If anything real shows up, do **not** push. Rotate the credential first.
---
## Disclaimer
**Use at your own risk.** This is an unofficial, personal-use tool. It is **not** affiliated with,
endorsed by, or sponsored by NTU Singapore, Anthology Inc., or Blackboard Learn.
- **Your account, your responsibility.** Driving the LMS with your session cookie may be
inconsistent with NTU's acceptable use policy. Check NTU policy if you are unsure.
- **Your cookie stays local.** It is read locally and sent only to `ntulearn.ntu.edu.sg`.
- **Don't share cookie values.** Anyone with your `BbRouter` can act as you until it expires.
- **Don't run this for someone else.** Each user should run their own instance against their own account.
MIT licensed — see [LICENSE](LICENSE). All warranties are disclaimed.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues