isu-moodle-mcp
by scatjay
README.md
# isu-moodle-mcp
把 Moodle 接進 Claude 的 MCP server。**分兩層**:
- **API 層(18 支,主力)**:官方 Web Service REST API。不爬 HTML、不用開瀏覽器。
- **CDP 層(6 支,補洞)+舊系統層(4 支)**:只在 API 真的拿不到的時候才用 debug Chrome。
最重要的一支是 `probe_course_access()` —— 它是唯一能發現「有一門課你已經看不到了」的辦法。
平常只用 API 層就夠。CDP 層是給「我教過的課都在清單裡嗎」這種問題準備的。
給義守大學 AIEA「課程設計 AI 實務」工作坊(2026-08-21)單元 3 使用。
**直接 pull 下來改成你自己的**,不需要 GitHub 帳號。
---
## 這是什麼
課堂上示範的 `flipclass-mcp` 是南臺科大 FlipClass 的 MCP server。那套系統沒有 API,
所以它靠兩件事拿資料:爬 HTML(32 處 xpath)+ debug Chrome(成績矩陣、成員名單這些
純 HTTP 讀不到的頁面)。**那是「系統再封閉,有帳密就能完整逆向」的示範。**
這一套是同一件事的另一半:**當對方有 API 的時候,同一組 MCP tool 契約可以整個抽換後端。**
| | flipclass-mcp | moodle-mcp |
|---|---|---|
| 取資料 | 爬 HTML + lxml xpath | 官方 REST API |
| 認證 | 帳密 + anticsrf token + cookie 快取 | 一組 token,無狀態 |
| 多重登入互踢 | 會(已知問題) | 不會 |
| 成員名單 / 成績矩陣 | **要開 debug Chrome 走 CDP** | 一般 API 就有 |
| 學生 email | 用學號拼字串推導 | 名單直接給 |
| 認證相關程式碼 | 約 247 行 | 約 15 行 |
| debug Chrome | **每次都要開**(沒它就沒成績矩陣) | 只在查選課關係時才要 |
**tool 名稱與 docstring 兩邊刻意保持一致**,這樣你可以直接對照,看同一個需求在
「有 API」和「沒 API」兩種情況下分別長什麼樣。
---
## 快速開始
### 1. 拿到程式
```bash
git clone https://github.com/scatjay/isu-moodle-mcp.git
```
沒有 git 也可以在 GitHub 頁面按 `Code → Download ZIP`。
### 2. 裝相依套件
```bash
pip install -r requirements.txt
```
只有兩個:`requests` 和 `mcp`。
### 3. 換一組 token
```bash
python get_token.py https://moodle.你的學校.edu.tw
```
它會問你的 Moodle 帳號密碼,成功就把 token 寫進 `.env`。
> **請在你自己的終端機跑這一步,不要在 AI 對話裡跑。**
> 對話逐字稿可能被保存或備份,密碼和 token 一旦出現在裡面就等於外洩。
**為什麼是 token 不是帳密?** token 可以撤銷、只綁你自己的權限、而且不會像密碼那樣
一洩就全盤皆輸。Moodle 的 token 預設 **12 週**到期——學期中工具突然壞掉、說
`invalidtoken`,回來重跑這支就好。
### 4. 接到 Claude
在 Claude Desktop 的設定檔(`claude_desktop_config.json`)加:
```json
{
"mcpServers": {
"moodle": {
"command": "python",
"args": ["C:/你的路徑/isu-moodle-mcp/server.py"],
"env": {
"MOODLE_URL": "https://moodle.你的學校.edu.tw",
"MOODLE_TOKEN": "貼上 .env 裡那一串",
"MOODLE_LEGACY_URL": "https://舊站網址(沒有舊站就整行刪掉)"
}
}
}
}
```
### 5. 先跑體檢
接好之後,第一句先叫 Claude 跑 `diagnose()`。它會告訴你 token 有沒有效、
你實際能呼叫哪些函式、缺了什麼。**接不上的時候第一個該跑的就是這支。**
---
## 有哪些工具
| Tool | 做什麼 |
|---|---|
| `diagnose()` | 連線體檢。接不上先跑這個 |
| `list_current_courses()` | 進行中的課 |
| `list_history_courses()` | 所有還看得到的課(**注意下面的已知限制**) |
| `search_courses(keyword)` | 用關鍵字找自己的課 |
| `get_course_overview(course_id)` | 課程有幾個單元、幾份教材、幾份作業 |
| `list_materials(course_id)` | 教材清單(含下載網址) |
| `list_homework(course_id)` | 作業清單 |
| `list_submissions(assignment_id)` | 全班繳交狀況 |
| `read_members(course_id)` | 修課名單(姓名 / email / 角色) |
| `read_score_matrix(course_id)` | 成績矩陣:每位學生 × 每個評分項目 |
| `get_completion_status(course_id)` | 活動完成度 |
| `download_file(fileurl, dest_path)` | 下載教材檔案 |
| `raw_call(wsfunction, params_json)` | 直接呼叫任意 Moodle 函式(探索用) |
| `get_submission_report(assignment_id)` | 繳交報表:含繳交時間、遲交、重繳次數 |
| `get_student_grade_record(course_id, uid)` | 單一學生的逐項成績 |
| `get_student_email(course_id, uid)` | 查某位學生的 email |
| `fetch_course_bundle(course_id, dest)` | 一門課的教材+作業+名單+成績,整包抓下來 |
| `fetch_all_courses_bundle(dest)` | 所有看得到的課,整批抓 |
### CDP 層(要先跑 `python start_debug_chrome_moodle.py` 並在那個視窗登入)
| Tool | 做什麼 |
|---|---|
| `cdp_status()` | debug Chrome 通不通、登入了沒 |
| `probe_course_access(course_id)` | **這門課我還進不進得去**——API 回答不了的那題 |
| `enrolment_details(course_id)` | 每一筆選課的狀態、方法、加選時間、起訖日 |
| `find_hidden_courses()` | 掃描找出「存在、但你已經看不到」的課 |
| `webservice_overview()` | 哪個服務綁了哪些函式、誰能自己領 token |
| `role_capabilities(role_id)` | 角色的 capability 矩陣(300+ 條,API 拿不到)|
### 舊系統層(學校換過平台時用)
需要在 `.env` 加一行 `MOODLE_LEGACY_URL=https://舊站網址`。
| Tool | 做什麼 |
|---|---|
| `legacy_status()` | 舊站活著嗎、走 API 還是走 CDP。**挖資料前先跑這支** |
| `legacy_list_courses()` | 舊站儀表板上看得到的課 |
| `legacy_probe_course(course_id)` | 舊站版的「這門課我還進不進得去」 |
| `legacy_course_contents(course_id)` | 舊站某門課的單元與教材連結 |
**這一版故意不含任何寫入工具**(例如改成績的 `mod_assign_save_grade`)。
唯讀的東西弄錯了頂多是資料不對;寫入弄錯了是真的改到學生成績。
真的需要再自己加,但請先在測試站練過。
---
## 已知限制(請務必讀完這一節)
### 🔴 舊課會安靜地消失——但條件比你想的窄
`core_enrol_get_users_courses` **只回「你目前還有選課關係」的課**。
**2026-08-20 用兩台本機 Moodle 4.1.18 實測,逐項驗過:**
| 學校做了什麼 | 課還在你的清單裡嗎 |
|---|---|
| 課程設成隱藏(`visible=0`) | **照樣看得到** |
| 課程結束日已經過了 | **照樣看得到** |
| **老師的選課關係被設為「已停用」** | **消失。而且完全不會報錯** |
> 這張表推翻了一個很常見的說法(也包括本 README 的前一版):
> 「隱藏或封存舊課會讓它消失」。**實測不成立。**
> 真正會讓課消失的只有最後那一列。
> 寫在這裡是因為:一個被實測推翻的說法留在文件裡,比沒寫還糟——
> 你會照著它去跟管理員要錯的東西。
課程、學生、作業都還在資料庫裡,只是你看不到。而 API 不會告訴你
「有一門課你看不到了」,它只是不提。
**所以要做長時段分析之前,先跑 `find_hidden_courses()` 或
`probe_course_access(course_id)` 逐一探測**,不要只信 `list_history_courses()` 的清單。
那支一定會回一個 `caveat` 欄位提醒你,請不要忽略它。
### Moodle 的錯誤是 HTTP 200
Moodle 回錯誤時 HTTP 狀態碼仍然是 200,錯誤藏在 body 的 `exception` 欄位裡。
`raise_for_status()` 完全抓不到。本 server 已經處理,但你自己寫程式打 Moodle 時要記得。
### `accessexception` 很難查
官方列出的成因有七八種,而**除非管理員把 debug 開到 NORMAL 以上,
錯誤訊息不會告訴你是哪一種**。本 server 會把它翻成白話並給出最可能的三個原因,
但真正要確定是哪一個,還是得跑 `diagnose()` 看你的 token 到底含哪些函式。
### 你只看得到自己的課
這是 Moodle 內建的保證,不是本工具的限制。token 完全繼承你本人的權限,
每次呼叫都會做 context 層級的權限檢查。**這同時是安全保證也是限制。**
### 陣列參數不能用 JSON
Moodle REST 用 PHP 的 `$_POST` 解析,陣列必須寫成 `courseids[0]=5&courseids[1]=7`。
丟 JSON 字串會被當成單一字串而報 `invalidparameter`。本 server 已自動攤平。
### 檔案下載的參數名不一樣
REST 端點用 `wstoken`,但 `webservice/pluginfile.php` 用的是 **`token`**。
移植時最容易漏掉這一點。而且服務的 `downloadfiles` 必須是開的。
---
## 如果 `get_token.py` 失敗
| 錯誤 | 意思 | 怎麼辦 |
|---|---|---|
| `invalidlogin` | 帳密不對 | Moodle 帳號未必等於你的 email |
| `servicenotavailable` | 站台沒開行動裝置服務 | 請管理員開 `enablemobilewebservice` |
| `cannotcreatetoken` | 你的帳號沒有自建 token 的權限 | 學校改過預設權限,需請管理員發 token |
| `sitemaintenance` | 站台維護中 | 等一下再試 |
Moodle 原廠預設把 `moodle/webservice:createmobiletoken` 給**所有已登入使用者**,
所以老師通常不需要管理員就能自己換 token。但學校可以改這個預設值——
**如果改過,只會在你實際去換的時候才發現**,從外面探測不出來。
---
## 開發筆記
這支是從 `flipclass-mcp` 移植過來的。移植時砍掉的是最痛的那一半、留下的是最有價值的那一半:
- **砍掉**(約 247 行):`_login`、anticsrf 處理、cookie 快取、`checkMultiLogin`
多重登入處理、32 處 lxml xpath 解析、CDP(debug Chrome)連線
- **保留**:FastMCP 骨架、每個 `@mcp.tool()` 的簽名與 docstring
——**這才是真正的資產**,因為那是 LLM 看到的契約
會這樣做,是因為現成的 Moodle Python 套件沒有一個能用:`moodlepy` 停更近兩年
且把相依鎖在 `attrs<23`(2022 年的版本);`moodle_api.py` 停更三年且不在 PyPI;
`python-moodle` 還在維護但根本是爬 HTML,不是 REST client。
Moodle REST 簡單到十幾行就寫完,引入停更套件只是多背一份技術債。
---
## 授權
MIT。拿去改成你自己學校的版本,不用問。
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing