Skip to main content
Glama
d1zcipline
by d1zcipline
README.md
# moodle-mcp

MCP server and CLI for [Moodle](https://moodle.org/): read courses, assignments, deadlines and files
from the terminal or straight from an AI agent — no browser automation, no scraping of interactive
pages in the common case.

It talks to the official Moodle **Web Services** API (`core_course_get_contents`,
`mod_assign_get_assignments`, …), falls back to the session-cookie HTML flow when a site restricts
the mobile service, and works around two network problems that break naive clients.

```
$ node cli.mjs courses
   12241  Big Data Warehousing and Technologies
   11190  English for Information Technologies. Part 4

$ node cli.mjs cm 365961
Module 365961: Pig - Hive practical task
  type:  assign
  course: 12241
  due: —
  max grade: 5
```

## Why this exists

Two things go wrong on the network this was written for, and both are handled automatically:

1. **A full-tunnel VPN kills TLS, not TCP.** The tunnel completes the TCP handshake and then
   silently drops the `ClientHello`, so every request ends in `ETIMEDOUT` even though `nc` says the
   port is open. The client binds its sockets to the physical interface (`en0`/`en1` on macOS,
   overridable with `MOODLE_LOCAL_ADDR`) instead of the tunnel and the site answers in ~400 ms.
   Browsers are usually unaffected because their traffic is shaped differently — so "it works in my
   browser" does not mean a script can reach it.
2. **The server sends an incomplete certificate chain.** Only the leaf is returned, signed by an
   intermediate that is not in Node's bundled root store (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`), while
   curl and browsers quietly fetch the missing link. The client reads the AIA "CA Issuers" URL from
   the leaf, downloads that intermediate, caches it in `.secrets/tls-chain.pem` and keeps validating
   against the real system roots — no `NODE_TLS_REJECT_UNAUTHORIZED=0` anywhere.

Both workarounds are no-ops on a normal network: binding falls back to a plain connection, and the
chain is only fetched when Node actually complains about it.

## Requirements

- Node.js **18 or newer**
- An account on a Moodle site with Web Services enabled (the standard `moodle_mobile_app` service —
  most universities have it for the mobile app) and, preferably, the
  [Moodle mobile service](https://docs.moodle.org/en/Web_services) enabled for your role.

## Install

```bash
git clone https://github.com/d1zcipline/moodle-mcp.git
cd moodle-mcp
npm ci                 # or: npm install
```

## Configure

Credentials live in `.secrets/moodle.json` (mode `600`):

```json
{
  "username": "your-login",
  "password": "your-password",
  "baseUrl": "https://online.mospolytech.ru"
}
```

The easy way:

```bash
bash setup-credentials.sh     # prompts, hides the password, writes the file
```

`baseUrl` is optional; without it the client uses `MOODLE_BASE_URL` and falls back to the default
site. Environment variables:

| Variable | Default | Meaning |
|---|---|---|
| `MOODLE_BASE_URL` | `https://online.mospolytech.ru` | Moodle site root, no trailing slash |
| `MOODLE_SECRETS_DIR` | `./.secrets` | Where `moodle.json` and the token cache live |
| `MOODLE_DATA_DIR` | `./moodle-data` | Where downloaded course material goes |
| `MOODLE_LOCAL_ADDR` | auto (`en0`, `en1`) | Source address to bypass a VPN tunnel |
| `MOODLE_NO_BIND` | unset | Set to `1` to never bind a source address |
| `MOODLE_SERVICE` | `moodle_mobile_app` | Web Services shortname used for the token |
| `MOODLE_DEBUG` | unset | Set to `1` for request-level logs on stderr |

## CLI

```bash
node cli.mjs probe                 # is the site reachable at all, with and without the bind
node cli.mjs login                 # sign in, print name and available API functions
node cli.mjs courses               # my courses
node cli.mjs tree <courseid>       # course outline as markdown
node cli.mjs cm <cmid>             # one module by id= from a Moodle URL
node cli.mjs assign <cmid>         # assignment JSON incl. submission status
node cli.mjs files <courseid>      # every downloadable file
node cli.mjs dump <courseid>       # mirror the whole course into ./moodle-data/<courseid>
```

`dump` writes `structure.md`, `course.json`, `summary.json`, every course file under `files/`, and
one folder per assignment with its description and attachments. Re-running it skips files whose
size already matches, so it is cheap to repeat. Flags: `--json`, `--no-download`, `--out DIR`.

## MCP

The server speaks stdio and exposes eight read-only tools.

DeepSeek Harness (`~/.dsh/profiles/desktop/cordis.patch.yml`):

```yaml
- insert:
    - id: mcp-moodle
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: moodle
        transport: stdio
        command: node
        args: [/absolute/path/to/moodle-mcp/mcp-server.mjs]
        cwd: /absolute/path/to/moodle-mcp
        failOnStartupError: false
```

Claude Desktop (`claude_desktop_config.json`) and most other clients:

```json
{
  "mcpServers": {
    "moodle": {
      "command": "node",
      "args": ["/absolute/path/to/moodle-mcp/mcp-server.mjs"],
      "env": { "MOODLE_BASE_URL": "https://online.mospolytech.ru" }
    }
  }
}
```

Ready-to-copy files are in [`examples/`](examples).

### Tools

| Tool | Arguments | Returns |
|---|---|---|
| `moodle_whoami` | — | name, user id, site release, number of API functions |
| `moodle_courses` | — | `id — fullname (shortname)` list |
| `moodle_course_structure` | `courseid` | markdown outline with due dates and files |
| `moodle_course_module` | `cmid` | module name/type/course, assignment due date and description |
| `moodle_assignment` | `cmid` or `assignid`+`courseid` | description, dates, grade, attachments, submission status |
| `moodle_list_files` | `courseid` | file list with sizes and `fileurl`s |
| `moodle_download_file` | `url`, `filename?`, `subdir?` | saved path and size |
| `moodle_dump_course` | `courseid`, `download?` | mirror of the whole course on disk |

There are deliberately **no** submission or upload tools: an agent can read everything and change
nothing in Moodle.

## What it stores

```
moodle-data/<courseid>/
  structure.md                  human-readable course outline
  course.json                   raw sections/modules from the API
  summary.json                  what was downloaded into this folder
  files/<section>/<file>        course files
  assignments/<cmid>-<name>/
    intro.md                    description, dates, grade, attachments
    assignment.json             raw assignment + submission status
    files/<file>                attachments
```

`.secrets/` holds the credentials, the Web Services token and the cached TLS intermediate — all of
it is git-ignored and never written into `moodle-data/`.

## Troubleshooting

| Symptom | Cause and fix |
|---|---|
| `Cannot reach … ETIMEDOUT` while the browser works | VPN tunnel drops TLS. `node cli.mjs probe` shows whether the bound attempt succeeds; set `MOODLE_LOCAL_ADDR=<ip>` or disable the VPN. |
| `Cannot verify the TLS certificate` | The AIA fetch failed (offline / blocked). Delete `.secrets/tls-chain.pem` and retry on a working network; the root is still verified. |
| `No credentials file` | Create `.secrets/moodle.json` or run `bash setup-credentials.sh`. |
| `Wrong Moodle username or password` | Moodle rejected the form **or** `login/token.php`. Check the account works in the browser. |
| `Cannot get a token: invalidservice` | The mobile Web Services service is disabled — ask the Moodle admin, or the client falls back to cookie mode. |
| Tools return data but file downloads 403 | The `fileurl` already carries a token; pass it unchanged instead of editing the URL. |
| Empty `moodle_list_files` | The course may only contain pages, links and forums — check `moodle_course_structure`. |

Run `MOODLE_DEBUG=1 node cli.mjs probe` for request-level detail.

## How it works

| File | Role |
|---|---|
| `lib/http.mjs` | HTTPS client: source-address binding, AIA chain repair, cookies, redirects, retries |
| `lib/auth.mjs` | form login (`logintoken`) and Web Services token, cached in `.secrets` |
| `lib/ws.mjs` | REST wrappers (`core_course_get_contents`, `mod_assign_*`, `core_files_*`) |
| `lib/course.mjs` | HTML→text, course outline rendering, mirroring to disk |
| `cli.mjs` | command line on top of the same library |
| `mcp-server.mjs` | stdio MCP server, eight read-only tools |

The token is cached; when Moodle invalidates it the client re-authenticates once and retries the
call, so a long-running MCP server survives password rotations and session timeouts.

## Security

- The password is only read from `.secrets/moodle.json` (mode `600`) and is never logged, printed or
  written into course dumps.
- The Web Services token is stored next to it with the same permissions; delete the file to revoke
  local access, and revoke the token itself in *Moodle → Preferences → Security → Keys*.
- The client never disables certificate verification and never follows links outside the configured
  Moodle host for authentication.
- Downloads are plain HTTP GETs against `pluginfile.php` with the token embedded by Moodle.

## License

MIT — see [LICENSE](LICENSE).

Not affiliated with Moodle Pty Ltd or any university. Use it on accounts you are allowed to use.