moodle-mcp
# moodle-mcp
A read-only MCP server that gives AI agents (Claude Code, Claude Desktop, claude.ai…) access to
your EPFL Moodle as a student: deadlines, course files, assignments, forums, grades.
It's built for [moodle.epfl.ch](https://moodle.epfl.ch) and for macOS, where it keeps your Moodle
key in the Keychain. It isn't affiliated with EPFL or Moodle.
## Tools
| Tool | What it answers |
|---|---|
| `upcoming_deadlines(days, course?)` | What's due soon, and whether Moodle still expects something from you |
| `list_courses(include_past?)` | Your courses (code, name, id) |
| `course_overview(course, section?)` | Sections and items of a course, with ids |
| `open_item(item_id, pages?)` | Any item: PDF text, assignment brief + submission status, page text, forum threads, folder files, links, quizzes |
| `read_file(file_url, pages?)` | Text of an attachment or folder file (also saved to `~/.cache/moodle-mcp/files`) |
| `search(query, course?)` | Find items by name across courses (Moodle's global search is disabled at EPFL) |
| `search_in_files(query, course?)` | Full-text search inside course PDFs, code and pages; returns pages + snippets and the call to read just those pages |
| `announcements(days, course?)` | Recent posts in Announcements forums |
| `read_discussion(discussion_id)` | A full forum thread |
| `grades(course)` | Grade items, scores and feedback |
| `whats_new(since?, course?)` | New/edited items, files, grades and forum posts since your last check (or `3d`, `2w`, a date) |
| `sync_course(course, folder?)` | Download a course's files, attachments and pages to `~/Moodle/<code>` with an `INDEX.md`; re-runs only fetch changes |
Courses accept `CS-214`, `cs214`, a numeric id, or part of the name.
Courses hidden on your Moodle dashboard are skipped unless named explicitly.
The tools only read from Moodle; `sync_course` writes only to your own disk.
### Token use
Long PDFs open as a **page map** (one line per page, repeated slide builds merged into ranges,
plus the PDF outline if it has one); the agent then asks for `pages="12-14"`. A 127-slide deck
costs ~1.4k tokens to map instead of ~9k to dump. `search_in_files` indexes a course's files on
first use (text cached in `~/.cache/moodle-mcp/files`, refreshed when a file changes on Moodle),
so later searches are instant and return only the matching pages.
## Install
You need macOS and [uv](https://docs.astral.sh/uv/) (it fetches Python 3.12+ itself). Clone this
repository, then from its folder:
```bash
claude mcp add --scope user moodle -- "$(command -v uv)" --directory "$PWD" run moodle-mcp
```
For Claude Desktop (chat/Cowork), add the same command to `mcpServers` in
`~/Library/Application Support/Claude/claude_desktop_config.json`, with absolute paths:
```json
{
"mcpServers": {
"moodle": {
"command": "/opt/homebrew/bin/uv",
"args": ["--directory", "/path/to/moodle-mcp", "run", "moodle-mcp"]
}
}
}
```
Then install the key helper (see below) and ask your agent anything about Moodle. Since no key
is stored yet, it answers with a link to get one.
```bash
sh scripts/install_url_handler.sh
```
## The Moodle key
The server uses a Moodle mobile web service token (the key the Moodle app uses), read from the
macOS Keychain item `moodle-epfl` or from `$MOODLE_TOKEN`. EPFL's *Security keys* page doesn't
hand one out, so it comes from the app sign-in flow: `launch.php` redirects a signed-in browser to
`moodlemobile://token=…`. Keys expire from time to time.
### Getting or renewing it
When the key has expired, or none is stored yet, tools answer with renewal steps that the agent
walks you through. You never paste the key into the chat. With *Moodle Key Helper* installed
(`scripts/install_url_handler.sh` puts it in `~/Applications`), it takes one link:
1. The local server makes up a random passport, saves it to `~/.cache/moodle-mcp/pending_renewal.json`
(valid 15 minutes) and gives you `launch.php?…&passport=<it>`.
2. You open the link in a browser signed in to Moodle (if you aren't, Moodle carries on after sign-in)
and let the browser open *Moodle Key Helper*. Moodle redirects to `moodlemobile://token=…`, which
holds the key signed with md5(site URL + passport).
3. The helper runs `save_token.py --from-app`. It accepts the link only if the signature matches the
pending passport, checks the key with Moodle, stores it in the Keychain, sends it to your hosted
server if you have one, and confirms with a notification. The running server picks it up on the
next call.
Any web page can open a `moodlemobile://` link, or send your browser to `launch.php` with a passport
of its own, but it can't know the pending one. So a page can't plant a key (for example, one for
another account), and links that arrive when no renewal is pending are ignored.
### By hand
Without the helper, the renewal steps also give the manual route, with the same link:
1. Signed in to moodle.epfl.ch in Chrome, open DevTools → Network (tick *Preserve log*).
2. Open the link (any passport works here, e.g.
`https://moodle.epfl.ch/admin/tool/mobile/launch.php?service=moodle_mobile_app&passport=12345&urlscheme=moodlemobile`).
3. Copy the `location: moodlemobile://token=…` header of the `launch.php` 303 response.
4. `python3 scripts/save_token.py` and paste it (input is hidden, and the key is never printed).
To revoke the key, reset the "Moodle mobile web service" key on moodle.epfl.ch → Preferences →
Security keys, then get a fresh one.
## On your phone, the web and cloud sessions (optional)
You can also host the server on [fly.io](https://fly.io) and add it as a claude.ai custom
connector. It then works in the Claude mobile apps, claude.ai, Claude Code on the web and
routines. Connector calls come from Anthropic's servers, so cloud sandboxes don't need network
access to it.
It's the same server with `MOODLE_MCP_URL` set, which switches it to HTTP behind a small OAuth
sign-in (`oauth.py`). Approving a connection takes a passphrase only you have, and sign-ins are
only handed back to Claude (claude.ai / claude.com) or to localhost. `sync_course` is left out
there, since it would save to the server's disk. The file cache, the `whats_new` marker and
sign-ins live on a volume.
Setup (once, with [flyctl](https://fly.io/docs/flyctl/install/) installed in `~/.fly/bin` and logged in):
1. `cp fly.example.toml fly.toml` and pick an app name. Set `MOODLE_MCP_URL` to where it will be
served: `https://<app>.fly.dev`, or your own domain (`flyctl certs add`). `fly.toml` is ignored
by git.
2. `flyctl apps create <app>` and `flyctl volumes create moodle_data --region cdg --size 1`.
3. `sh scripts/setup_hosted.sh`: creates the passphrase (Keychain item `moodle-mcp-remote`) and sends it
and your Moodle key to fly as secrets, without printing either.
4. `flyctl deploy` (also after code changes).
5. On claude.ai: Settings → Connectors → Add custom connector, with your `MOODLE_MCP_URL` followed
by `/mcp`. When the sign-in page asks, paste the passphrase:
`security find-generic-password -s moodle-mcp-remote -w | pbcopy`
When the Moodle key expires, renew it on your Mac: `save_token.py` also sends the new key to the
hosted server (the app in `fly.toml`), which restarts with it. The hosted server can't start a
renewal on your Mac, so it only gives the manual steps. For the one-link renewal, ask the local
server anything about Moodle instead: it uses the same key, so it gives you the link.
To disconnect every client: `flyctl ssh console -C "rm /data/oauth.json"`, then `flyctl apps restart <app>`.
## Configuration
| Variable | Default | Purpose |
|---|---|---|
| `MOODLE_TOKEN` | – | Use this key instead of the Keychain (e.g. on the hosted server) |
| `MOODLE_KEYCHAIN_SERVICE` | `moodle-epfl` | Keychain item holding the key |
| `MOODLE_MCP_STATE_DIR` | `~/.cache/moodle-mcp` | File cache, `whats_new` marker, pending renewal, sign-ins |
| `MOODLE_MCP_URL` | – | Public URL; when set, serve over HTTP with OAuth sign-in |
| `MCP_PASSPHRASE` | – | Passphrase that approves connections to the hosted server |
| `PORT` | `8080` | HTTP port of the hosted server |
## License
MIT, see [LICENSE](LICENSE). PDF text extraction uses [PyMuPDF](https://pymupdf.readthedocs.io),
which is licensed under the AGPL-3.0 (or a commercial license from Artifex).
TDQS
Scored across 12 tools
Most tools have clearly distinct purposes: listing courses, deadlines, course content, files, discussions, announcements, grades, search, and sync. However, open_item and read_file both handle file content, and search vs search_in_files could be confused (name search vs full-text search), though descriptions clarify the difference.
Tool names mostly follow a verb_noun pattern (list_courses, open_item, read_file, read_discussion, sync_course). Minor deviations: 'search' and 'search_in_files' are less consistent than 'search_items'/'search_files', and 'whats_new' lacks an underscore, but overall the pattern is readable and predictable.
12 tools is well-scoped for a Moodle assistant covering course browsing, deadlines, content retrieval, search, grades, announcements, and offline sync. Each tool serves a distinct workflow need without redundancy.
The surface covers the main student workflows: listing courses, viewing deadlines, reading content, searching, checking grades, and syncing. Minor gaps: no tool to submit assignments, create forum posts, or manage calendar events, but these are likely outside the server's read-oriented purpose.