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 Rust binary** built on
[`ultrafast-mcp`](https://github.com/techgopal/ultrafast-mcp), with 21 tools, 2 prompt templates, and
a course-resource URI. No Python, no pip, no virtualenv.
**Website:** <https://ntulearn-mcp.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 throwaway-profile browser and captures your session cookie
for you. No copy-paste, no devtools, no keychain prompts.
```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) | 6,639 (every turn) | 586 (once, on demand) | **11.3x** |
| Task output tokens (4-task suite) | 2,193 | 492 (compact TSV) | **4.5x** |
| 10-turn conversation model | ~68,583 tok | ~1,078 tok | **63.6x** |
| `courses` cold (2 RTTs, 25ms mock) | — | 59ms | **3.4x** vs v0.3 |
| `courses` warm (SQLite cache) | — | 82µs | — |
| 4 commands concurrently (`multi`) | 225ms sequential | 59ms (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: "toon"` too.
---
## 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='json'`; pass `'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. When NTULearn rejects a
call with 401, the server re-resolves the cookie, persists a working value, and retries once.
Refresh is never proactive; it happens on a live 401 or when you run `ntulearn-mcp refresh`.
`setup` is the normal first-run path: it validates any existing cookie live, and if there is none,
opens Chrome/Arc/Brave/Edge/Chromium with a throwaway profile and polls the DevTools protocol for
your cookie (15-minute login timeout). The captured value is validated against the API before it is
saved, then the browser and profile are cleaned up. On a headless server with no supported browser,
it falls back to a one-time paste.
The cookie expires with your NTULearn session (days to weeks). Re-run `setup` when it does.
<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_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_MCP_AUTOUPDATE` | `ask` | `on`, `off`, or `ask`. |
| `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
The binary checks GitHub for a newer release at most once every 24 hours, and never blocks or breaks
the server. When one is available you get a one-line notice in the MCP host's log and at the start
of the session.
```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/).
**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
- **"No NTULearn cookie found", or tools fail with 401** — run `ntulearn-mcp check` to see the
cookie source and live validity, then `ntulearn-mcp setup` to re-capture one.
- **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"** — that content node is not a file (a text
page or tool link, say). Use `download_file` for real attached files.
---
## Development
```bash
cd rust
cargo test # 53 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 issues