mcp-moodle 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ MOODLE_URL=https://moodle.epita.fr
2
+ MOODLE_TOKEN=
@@ -0,0 +1,31 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ name: Build and publish to PyPI
11
+ runs-on: ubuntu-latest
12
+ environment:
13
+ name: pypi
14
+ url: https://pypi.org/p/mcp-moodle
15
+ permissions:
16
+ id-token: write
17
+ contents: read
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v5
24
+ with:
25
+ enable-cache: true
26
+
27
+ - name: Build distribution
28
+ run: uv build
29
+
30
+ - name: Publish to PyPI
31
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ .env
2
+ .env.local
3
+ .venv/
4
+ __pycache__/
5
+ *.pyc
6
+ .DS_Store
7
+ *.token
8
+ dist/
9
+ build/
10
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arthur Lefebvre
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-moodle
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing Moodle Web Services to AI assistants (Claude, Cursor, etc.)
5
+ Project-URL: Homepage, https://github.com/Snaw80/moodle-mcp
6
+ Project-URL: Repository, https://github.com/Snaw80/moodle-mcp
7
+ Project-URL: Issues, https://github.com/Snaw80/moodle-mcp/issues
8
+ Author-email: Arthur Lefebvre <arthurloup.lefebvre@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,claude,llm,mcp,moodle
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Education
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: mcp[cli]>=1.2.0
24
+ Provides-Extra: token
25
+ Requires-Dist: playwright>=1.40; extra == 'token'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # mcp-moodle
29
+
30
+ An [MCP](https://modelcontextprotocol.io) server that exposes
31
+ [Moodle Web Services](https://docs.moodle.org/dev/Web_services) to any
32
+ MCP-compatible AI assistant — Claude Code, Claude Desktop, Cursor, Codex,
33
+ and others.
34
+
35
+ Ask your assistant things like _"what's due this week?"_, _"list my courses"_,
36
+ _"download the slides from CS101 week 3"_ — without leaving the chat.
37
+
38
+ ## Features
39
+
40
+ - **`site_info`** — verify the token and get the authenticated user
41
+ - **`list_my_courses`** — courses you're enrolled in
42
+ - **`get_course_contents`** — sections, modules, file URLs
43
+ - **`search_courses`** — search the public catalog
44
+ - **`list_assignments`** — assignments across one or all courses
45
+ - **`upcoming_events`** — calendar deadlines and sessions
46
+ - **`get_user_grades`** — your grades for a course
47
+ - **`download_file`** — save any Moodle file locally (token appended automatically)
48
+
49
+ Works with any Moodle 3.5+ instance that has Web Services enabled.
50
+
51
+ ## Install
52
+
53
+ The recommended way is [`uv`](https://docs.astral.sh/uv/) — no virtualenv to manage:
54
+
55
+ ```bash
56
+ # One-off run (no install)
57
+ uvx mcp-moodle
58
+
59
+ # Or persist as a tool
60
+ uv tool install mcp-moodle
61
+ ```
62
+
63
+ Plain pip works too:
64
+
65
+ ```bash
66
+ pip install mcp-moodle
67
+ ```
68
+
69
+ ## Get a token
70
+
71
+ Moodle Web Services require a personal token. The package ships a helper that
72
+ handles every common login flow — native accounts, SSO (Microsoft, Google,
73
+ SAML, OAuth), or manual paste:
74
+
75
+ ```bash
76
+ # Default: opens a Chromium window, you complete SSO, token is captured
77
+ uvx --from "mcp-moodle[token]" mcp-moodle-token https://moodle.example.org
78
+
79
+ # Native (non-SSO) account
80
+ uvx --from "mcp-moodle[token]" mcp-moodle-token https://moodle.example.org \
81
+ --method local --user jdoe
82
+
83
+ # Headless server fallback (paste the moodlemobile:// URL by hand)
84
+ uvx --from "mcp-moodle[token]" mcp-moodle-token https://moodle.example.org \
85
+ --method manual-mobile
86
+ ```
87
+
88
+ The token is written to `./.env` (chmod 600) as `MOODLE_URL` and `MOODLE_TOKEN`.
89
+ Pass `--stdout` to print it to stdout instead.
90
+
91
+ > The `[token]` extra pulls in Playwright. First run downloads Chromium
92
+ > (~150 MB, one-time). Skip the extra if you only ever use `--method local`,
93
+ > `--method web`, or `--method manual-mobile`.
94
+
95
+ ## Configure your MCP client
96
+
97
+ ### Claude Code
98
+
99
+ ```bash
100
+ claude mcp add moodle \
101
+ --env MOODLE_URL=https://moodle.example.org \
102
+ --env MOODLE_TOKEN=your_token_here \
103
+ -- uvx mcp-moodle
104
+ ```
105
+
106
+ ### Claude Desktop
107
+
108
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
109
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
110
+
111
+ ```json
112
+ {
113
+ "mcpServers": {
114
+ "moodle": {
115
+ "command": "uvx",
116
+ "args": ["mcp-moodle"],
117
+ "env": {
118
+ "MOODLE_URL": "https://moodle.example.org",
119
+ "MOODLE_TOKEN": "your_token_here"
120
+ }
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ ### Cursor / other clients
127
+
128
+ Any MCP client that supports stdio servers works the same way: command
129
+ `uvx`, args `["mcp-moodle"]`, env `MOODLE_URL` and `MOODLE_TOKEN`.
130
+
131
+ ## Verify it works
132
+
133
+ In your MCP client, ask: _"call the moodle site_info tool"_. You should see
134
+ your name, username, and the site URL.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ git clone git@github.com:Snaw80/moodle-mcp.git
140
+ cd moodle-mcp
141
+ uv sync --all-extras
142
+ uv run mcp-moodle
143
+ ```
144
+
145
+ ## Security notes
146
+
147
+ - Your token is the equivalent of a password for Moodle Web Services — keep
148
+ `.env` out of version control (the included `.gitignore` already does this).
149
+ - The server reads `MOODLE_TOKEN` from the environment and never logs it.
150
+ - `download_file` appends the token to the URL; that URL is not logged either,
151
+ but be mindful if your client echoes tool arguments.
152
+
153
+ ## License
154
+
155
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,128 @@
1
+ # mcp-moodle
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that exposes
4
+ [Moodle Web Services](https://docs.moodle.org/dev/Web_services) to any
5
+ MCP-compatible AI assistant — Claude Code, Claude Desktop, Cursor, Codex,
6
+ and others.
7
+
8
+ Ask your assistant things like _"what's due this week?"_, _"list my courses"_,
9
+ _"download the slides from CS101 week 3"_ — without leaving the chat.
10
+
11
+ ## Features
12
+
13
+ - **`site_info`** — verify the token and get the authenticated user
14
+ - **`list_my_courses`** — courses you're enrolled in
15
+ - **`get_course_contents`** — sections, modules, file URLs
16
+ - **`search_courses`** — search the public catalog
17
+ - **`list_assignments`** — assignments across one or all courses
18
+ - **`upcoming_events`** — calendar deadlines and sessions
19
+ - **`get_user_grades`** — your grades for a course
20
+ - **`download_file`** — save any Moodle file locally (token appended automatically)
21
+
22
+ Works with any Moodle 3.5+ instance that has Web Services enabled.
23
+
24
+ ## Install
25
+
26
+ The recommended way is [`uv`](https://docs.astral.sh/uv/) — no virtualenv to manage:
27
+
28
+ ```bash
29
+ # One-off run (no install)
30
+ uvx mcp-moodle
31
+
32
+ # Or persist as a tool
33
+ uv tool install mcp-moodle
34
+ ```
35
+
36
+ Plain pip works too:
37
+
38
+ ```bash
39
+ pip install mcp-moodle
40
+ ```
41
+
42
+ ## Get a token
43
+
44
+ Moodle Web Services require a personal token. The package ships a helper that
45
+ handles every common login flow — native accounts, SSO (Microsoft, Google,
46
+ SAML, OAuth), or manual paste:
47
+
48
+ ```bash
49
+ # Default: opens a Chromium window, you complete SSO, token is captured
50
+ uvx --from "mcp-moodle[token]" mcp-moodle-token https://moodle.example.org
51
+
52
+ # Native (non-SSO) account
53
+ uvx --from "mcp-moodle[token]" mcp-moodle-token https://moodle.example.org \
54
+ --method local --user jdoe
55
+
56
+ # Headless server fallback (paste the moodlemobile:// URL by hand)
57
+ uvx --from "mcp-moodle[token]" mcp-moodle-token https://moodle.example.org \
58
+ --method manual-mobile
59
+ ```
60
+
61
+ The token is written to `./.env` (chmod 600) as `MOODLE_URL` and `MOODLE_TOKEN`.
62
+ Pass `--stdout` to print it to stdout instead.
63
+
64
+ > The `[token]` extra pulls in Playwright. First run downloads Chromium
65
+ > (~150 MB, one-time). Skip the extra if you only ever use `--method local`,
66
+ > `--method web`, or `--method manual-mobile`.
67
+
68
+ ## Configure your MCP client
69
+
70
+ ### Claude Code
71
+
72
+ ```bash
73
+ claude mcp add moodle \
74
+ --env MOODLE_URL=https://moodle.example.org \
75
+ --env MOODLE_TOKEN=your_token_here \
76
+ -- uvx mcp-moodle
77
+ ```
78
+
79
+ ### Claude Desktop
80
+
81
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
82
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "moodle": {
88
+ "command": "uvx",
89
+ "args": ["mcp-moodle"],
90
+ "env": {
91
+ "MOODLE_URL": "https://moodle.example.org",
92
+ "MOODLE_TOKEN": "your_token_here"
93
+ }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ### Cursor / other clients
100
+
101
+ Any MCP client that supports stdio servers works the same way: command
102
+ `uvx`, args `["mcp-moodle"]`, env `MOODLE_URL` and `MOODLE_TOKEN`.
103
+
104
+ ## Verify it works
105
+
106
+ In your MCP client, ask: _"call the moodle site_info tool"_. You should see
107
+ your name, username, and the site URL.
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ git clone git@github.com:Snaw80/moodle-mcp.git
113
+ cd moodle-mcp
114
+ uv sync --all-extras
115
+ uv run mcp-moodle
116
+ ```
117
+
118
+ ## Security notes
119
+
120
+ - Your token is the equivalent of a password for Moodle Web Services — keep
121
+ `.env` out of version control (the included `.gitignore` already does this).
122
+ - The server reads `MOODLE_TOKEN` from the environment and never logs it.
123
+ - `download_file` appends the token to the URL; that URL is not logged either,
124
+ but be mindful if your client echoes tool arguments.
125
+
126
+ ## License
127
+
128
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-moodle"
7
+ version = "0.1.0"
8
+ description = "MCP server exposing Moodle Web Services to AI assistants (Claude, Cursor, etc.)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Arthur Lefebvre", email = "arthurloup.lefebvre@gmail.com" }]
13
+ keywords = ["mcp", "moodle", "claude", "ai", "llm"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Education",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Education",
24
+ ]
25
+ dependencies = [
26
+ "mcp[cli]>=1.2.0",
27
+ "httpx>=0.27",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ token = ["playwright>=1.40"]
32
+
33
+ [project.scripts]
34
+ mcp-moodle = "moodle_mcp.server:main"
35
+ mcp-moodle-token = "moodle_mcp.get_token:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/Snaw80/moodle-mcp"
39
+ Repository = "https://github.com/Snaw80/moodle-mcp"
40
+ Issues = "https://github.com/Snaw80/moodle-mcp/issues"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/moodle_mcp"]
File without changes
@@ -0,0 +1,393 @@
1
+ """Fetch a Moodle Web Services token via one of several methods.
2
+
3
+ Methods:
4
+ local POST login/token.php with username/password.
5
+ Works for native Moodle accounts. Fails on SSO-only sites.
6
+ web Open user/managetoken.php in the browser. After SSO login,
7
+ copy the displayed token. Best for SSO sites that allow
8
+ users to self-serve tokens. Fragile: some Moodle themes
9
+ show a non-API field that looks like a valid token.
10
+ mobile Default. Launches a Playwright-controlled Chromium, you
11
+ complete SSO inside it, and the moodlemobile:// redirect
12
+ is captured at the network level. Verifies the signature
13
+ against md5(wwwroot + passport). Works with Microsoft
14
+ Azure AD, Google, SAML, OAuth — any real-browser SSO.
15
+ First run downloads Chromium (~150MB, one-time).
16
+ manual-mobile Same flow but in your default browser; you paste the
17
+ resulting moodlemobile:// URL by hand. Fallback when
18
+ Playwright can't run (e.g. headless server, no GUI).
19
+
20
+ By default the token is written to ./.env (chmod 600) and only a masked
21
+ preview is shown. Pass --stdout to print the full token instead (for use
22
+ in pipes/redirects); never let it land in scrollback.
23
+
24
+ Usage:
25
+ mcp-moodle-token https://moodle.example.org
26
+ mcp-moodle-token https://moodle.example.org --method web
27
+ mcp-moodle-token https://moodle.example.org --method local --user jdoe
28
+ mcp-moodle-token https://moodle.example.org --method manual-mobile
29
+ mcp-moodle-token https://moodle.example.org --env-file path/.env
30
+ mcp-moodle-token https://moodle.example.org --stdout > my-token.txt
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import base64
37
+ import getpass
38
+ import hashlib
39
+ import re
40
+ import secrets
41
+ import sys
42
+ import webbrowser
43
+ from pathlib import Path
44
+
45
+ import httpx
46
+
47
+ _MOODLEMOBILE_RE = re.compile(r"moodlemobile://token=([A-Za-z0-9+/=]+)")
48
+
49
+
50
+ def _decode_payload(b64: str) -> tuple[str | None, str | None]:
51
+ """Decode a moodlemobile token payload into (signature, token)."""
52
+ try:
53
+ decoded = base64.b64decode(b64).decode("utf-8", errors="replace")
54
+ except Exception as e:
55
+ print(f" Base64 decode failed: {e}", file=sys.stderr)
56
+ return None, None
57
+ parts = decoded.split(":::")
58
+ if len(parts) < 2:
59
+ print(f" Unexpected payload: {decoded[:80]}", file=sys.stderr)
60
+ return None, None
61
+ return parts[0], parts[1]
62
+
63
+
64
+ def _verify_signature(signature: str, base: str, passport: str) -> bool:
65
+ """Moodle signs the response with md5(wwwroot + passport)."""
66
+ expected = hashlib.md5(f"{base}{passport}".encode()).hexdigest()
67
+ return signature == expected
68
+
69
+
70
+ def method_local(base: str, username: str | None) -> str | None:
71
+ if username is None:
72
+ username = input(" Username: ").strip()
73
+ password = getpass.getpass(" Password: ")
74
+ try:
75
+ r = httpx.post(
76
+ f"{base}/login/token.php",
77
+ data={
78
+ "username": username,
79
+ "password": password,
80
+ "service": "moodle_mobile_app",
81
+ },
82
+ timeout=30.0,
83
+ )
84
+ r.raise_for_status()
85
+ data = r.json()
86
+ except httpx.HTTPError as e:
87
+ print(f" HTTP error: {e}", file=sys.stderr)
88
+ return None
89
+
90
+ if "token" in data:
91
+ return data["token"]
92
+ err = data.get("errorcode") or data.get("error") or data
93
+ print(f" Rejected: {err}", file=sys.stderr)
94
+ return None
95
+
96
+
97
+ def method_web(base: str) -> str | None:
98
+ url = f"{base}/user/managetoken.php"
99
+ print(f" Opening {url}")
100
+ print(" Complete SSO if prompted, then look under 'Moodle mobile web")
101
+ print(" service' (or similar). Copy the token and paste it below.")
102
+ try:
103
+ webbrowser.open(url)
104
+ except Exception:
105
+ pass
106
+ token = getpass.getpass(" Paste token (hidden, empty to skip): ").strip()
107
+ return token or None
108
+
109
+
110
+ def _ensure_playwright_chromium() -> bool:
111
+ """Install Playwright's Chromium binary on demand, once."""
112
+ import subprocess
113
+
114
+ print(
115
+ " Playwright's Chromium isn't installed yet.",
116
+ " Installing now (~150MB, one-time)…",
117
+ sep="\n",
118
+ file=sys.stderr,
119
+ )
120
+ result = subprocess.run(
121
+ [sys.executable, "-m", "playwright", "install", "chromium"],
122
+ capture_output=False,
123
+ )
124
+ return result.returncode == 0
125
+
126
+
127
+ def method_mobile(base: str) -> str | None:
128
+ """Launch a Playwright-controlled Chromium, auto-capture token.
129
+
130
+ Plain pywebview-style approaches stall inside Microsoft Azure AD's
131
+ auto-form-submit step (third-party cookies and SSO JS quirks). A real
132
+ Chromium instance driven by Playwright handles every common SSO
133
+ provider, and Playwright lets us intercept the moodlemobile:// URL
134
+ at the network-request layer regardless of whether Moodle emits a
135
+ server-side 303 or a JS-based redirect.
136
+ """
137
+ try:
138
+ from playwright.sync_api import ( # type: ignore
139
+ Error as PlaywrightError,
140
+ sync_playwright,
141
+ )
142
+ except ImportError as e:
143
+ print(
144
+ f" playwright not available ({e}).",
145
+ " Use --method manual-mobile, or run:",
146
+ " pip install playwright && playwright install chromium",
147
+ sep="\n",
148
+ file=sys.stderr,
149
+ )
150
+ return None
151
+
152
+ import time
153
+
154
+ passport = secrets.token_hex(16)
155
+ launch = (
156
+ f"{base}/admin/tool/mobile/launch.php"
157
+ f"?service=moodle_mobile_app&passport={passport}&urlscheme=moodlemobile"
158
+ )
159
+
160
+ captured: dict[str, str | None] = {"url": None}
161
+
162
+ print(f" Launching Chromium (passport: {passport[:8]}…).")
163
+ print(" Complete SSO in the window that opens. It will close itself")
164
+ print(" the moment the moodlemobile:// redirect fires.")
165
+
166
+ try:
167
+ with sync_playwright() as p:
168
+ try:
169
+ browser = p.chromium.launch(headless=False)
170
+ except PlaywrightError as e:
171
+ msg = str(e)
172
+ if "Executable doesn't exist" in msg or "playwright install" in msg:
173
+ if not _ensure_playwright_chromium():
174
+ print(" Chromium install failed.", file=sys.stderr)
175
+ return None
176
+ browser = p.chromium.launch(headless=False)
177
+ else:
178
+ raise
179
+
180
+ context = browser.new_context()
181
+ page = context.new_page()
182
+
183
+ def on_request(request):
184
+ if captured["url"]:
185
+ return
186
+ if request.url.startswith("moodlemobile://"):
187
+ captured["url"] = request.url
188
+
189
+ def on_response(response):
190
+ # Some Moodle versions emit a server-side 303 instead of JS.
191
+ # The Location header carries the moodlemobile URL.
192
+ if captured["url"]:
193
+ return
194
+ if 300 <= response.status < 400:
195
+ loc = response.headers.get("location", "")
196
+ if loc.startswith("moodlemobile://"):
197
+ captured["url"] = loc
198
+
199
+ page.on("request", on_request)
200
+ page.on("response", on_response)
201
+
202
+ try:
203
+ page.goto(launch, wait_until="domcontentloaded")
204
+ except PlaywrightError:
205
+ # Goto can raise once Chromium hits moodlemobile:// — ignore.
206
+ pass
207
+
208
+ deadline = time.time() + 300
209
+ last_url = None
210
+ while time.time() < deadline and captured["url"] is None:
211
+ if page.is_closed():
212
+ print(" Window closed before SSO finished.", file=sys.stderr)
213
+ break
214
+ try:
215
+ current = page.url
216
+ except PlaywrightError:
217
+ current = ""
218
+ if current and current != last_url:
219
+ shown = current.split("?", 1)[0]
220
+ print(
221
+ f" [{time.strftime('%H:%M:%S')}] page: {shown[:90]}",
222
+ file=sys.stderr,
223
+ )
224
+ last_url = current
225
+ # Secondary path: scrape the DOM for a JS-emitted URL.
226
+ if not captured["url"]:
227
+ try:
228
+ m = _MOODLEMOBILE_RE.search(page.content())
229
+ if m:
230
+ captured["url"] = f"moodlemobile://token={m.group(1)}"
231
+ except PlaywrightError:
232
+ pass
233
+ page.wait_for_timeout(400)
234
+
235
+ try:
236
+ browser.close()
237
+ except Exception:
238
+ pass
239
+ except Exception as e:
240
+ print(f" Playwright error: {e}", file=sys.stderr)
241
+ return None
242
+
243
+ raw = captured["url"]
244
+ if not raw:
245
+ print(
246
+ " Timed out without capturing a token.",
247
+ " Re-run with --method manual-mobile as a fallback.",
248
+ sep="\n",
249
+ file=sys.stderr,
250
+ )
251
+ return None
252
+
253
+ if "token=" not in raw:
254
+ print(f" Unexpected capture: {raw[:80]}", file=sys.stderr)
255
+ return None
256
+
257
+ b64 = raw.split("token=", 1)[1].split("&", 1)[0].split("#", 1)[0]
258
+ signature, token = _decode_payload(b64)
259
+ if not token:
260
+ return None
261
+ if signature and not _verify_signature(signature, base, passport):
262
+ print(
263
+ f" WARNING: signature mismatch (got {signature[:8]}…). "
264
+ "Token captured but origin not verified — refusing.",
265
+ file=sys.stderr,
266
+ )
267
+ return None
268
+ return token
269
+
270
+
271
+ def method_manual_mobile(base: str) -> str | None:
272
+ passport = secrets.token_hex(16)
273
+ launch = (
274
+ f"{base}/admin/tool/mobile/launch.php"
275
+ f"?service=moodle_mobile_app&passport={passport}&urlscheme=moodlemobile"
276
+ )
277
+ print(f" Opening {launch}")
278
+ print(" Complete SSO. The browser will try to open a moodlemobile://")
279
+ print(" URL and show an error — that's expected. Copy the full URL")
280
+ print(" from the address bar (or the error message) and paste below.")
281
+ try:
282
+ webbrowser.open(launch)
283
+ except Exception:
284
+ pass
285
+
286
+ raw = getpass.getpass(" Paste moodlemobile://... URL (hidden): ").strip()
287
+ if not raw or "token=" not in raw:
288
+ print(" No token= found in URL", file=sys.stderr)
289
+ return None
290
+
291
+ b64 = raw.split("token=", 1)[1].split("&", 1)[0].split("#", 1)[0]
292
+ signature, token = _decode_payload(b64)
293
+ if not token:
294
+ return None
295
+ if signature and not _verify_signature(signature, base, passport):
296
+ print(
297
+ f" WARNING: signature mismatch (got {signature[:8]}…). "
298
+ "Token captured but origin not verified — refusing.",
299
+ file=sys.stderr,
300
+ )
301
+ return None
302
+ return token
303
+
304
+
305
+ def write_env(env_file: Path, base: str, token: str) -> None:
306
+ env_file = env_file.expanduser().resolve()
307
+ env_file.parent.mkdir(parents=True, exist_ok=True)
308
+ preserved: list[str] = []
309
+ if env_file.exists():
310
+ for line in env_file.read_text().splitlines():
311
+ if line.startswith(("MOODLE_URL=", "MOODLE_TOKEN=")):
312
+ continue
313
+ preserved.append(line)
314
+ body = "\n".join(preserved + [f"MOODLE_URL={base}", f"MOODLE_TOKEN={token}"])
315
+ env_file.write_text(body + "\n")
316
+ env_file.chmod(0o600)
317
+ print(f" Wrote {env_file} (chmod 600)", file=sys.stderr)
318
+
319
+
320
+ def mask(token: str) -> str:
321
+ if len(token) <= 8:
322
+ return "*" * len(token)
323
+ return f"{token[:4]}…{token[-4:]} ({len(token)} chars)"
324
+
325
+
326
+ def main() -> int:
327
+ p = argparse.ArgumentParser(
328
+ description="Fetch a Moodle Web Services token.",
329
+ formatter_class=argparse.RawDescriptionHelpFormatter,
330
+ )
331
+ p.add_argument("moodle_url", help="e.g. https://moodle.example.org")
332
+ p.add_argument(
333
+ "--method",
334
+ choices=["auto", "local", "web", "mobile", "manual-mobile"],
335
+ default="auto",
336
+ help=(
337
+ "auto: 'local' (if --user given) → 'mobile' (Playwright Chromium). "
338
+ "'mobile' is the cross-SSO default."
339
+ ),
340
+ )
341
+ p.add_argument("--user", help="Username (enables auto-trying 'local').")
342
+ p.add_argument(
343
+ "--env-file",
344
+ type=Path,
345
+ default=Path(".env"),
346
+ help="Where to write MOODLE_URL/MOODLE_TOKEN (default: ./.env, chmod 600).",
347
+ )
348
+ p.add_argument(
349
+ "--stdout",
350
+ action="store_true",
351
+ help="Print the raw token to stdout instead of writing a file.",
352
+ )
353
+ args = p.parse_args()
354
+
355
+ base = args.moodle_url.rstrip("/")
356
+
357
+ if args.method == "auto":
358
+ methods = (["local"] if args.user else []) + ["mobile"]
359
+ else:
360
+ methods = [args.method]
361
+
362
+ runners = {
363
+ "local": lambda: method_local(base, args.user),
364
+ "web": lambda: method_web(base),
365
+ "mobile": lambda: method_mobile(base),
366
+ "manual-mobile": lambda: method_manual_mobile(base),
367
+ }
368
+
369
+ token: str | None = None
370
+ for i, m in enumerate(methods):
371
+ print(f"\n→ Method: {m}")
372
+ token = runners[m]()
373
+ if token:
374
+ break
375
+ if i < len(methods) - 1:
376
+ ans = input(" Try next method? [Y/n] ").strip().lower()
377
+ if ans == "n":
378
+ break
379
+
380
+ if not token:
381
+ print("\nNo token obtained.", file=sys.stderr)
382
+ return 2
383
+
384
+ if args.stdout:
385
+ print(token)
386
+ else:
387
+ write_env(args.env_file, base, token)
388
+ print(f" Token: {mask(token)}", file=sys.stderr)
389
+ return 0
390
+
391
+
392
+ if __name__ == "__main__":
393
+ sys.exit(main())
@@ -0,0 +1,195 @@
1
+ """Moodle MCP server.
2
+
3
+ Exposes Moodle Web Services (REST) as MCP tools. Works with any MCP client
4
+ (Claude Code, Claude Desktop, Codex, Cursor, etc.) over stdio.
5
+
6
+ Required env vars:
7
+ MOODLE_URL e.g. https://moodle.example.org
8
+ MOODLE_TOKEN Web Services token (see `mcp-moodle-token`)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import httpx
18
+ from mcp.server.fastmcp import FastMCP
19
+
20
+ mcp = FastMCP("moodle")
21
+
22
+
23
+ def _config() -> tuple[str, str]:
24
+ url = os.environ.get("MOODLE_URL", "").rstrip("/")
25
+ token = os.environ.get("MOODLE_TOKEN", "")
26
+ if not url:
27
+ raise SystemExit("MOODLE_URL env var is required")
28
+ if not token:
29
+ raise SystemExit("MOODLE_TOKEN env var is required")
30
+ return url, token
31
+
32
+
33
+ def _flatten(params: dict[str, Any]) -> dict[str, Any]:
34
+ """Moodle expects PHP-style array params: key[0]=a&key[1]=b."""
35
+ out: dict[str, Any] = {}
36
+ for k, v in params.items():
37
+ if v is None:
38
+ continue
39
+ if isinstance(v, list):
40
+ for i, item in enumerate(v):
41
+ out[f"{k}[{i}]"] = item
42
+ else:
43
+ out[k] = v
44
+ return out
45
+
46
+
47
+ async def _call(fn: str, **params: Any) -> Any:
48
+ url, token = _config()
49
+ payload = {
50
+ "wstoken": token,
51
+ "wsfunction": fn,
52
+ "moodlewsrestformat": "json",
53
+ **_flatten(params),
54
+ }
55
+ async with httpx.AsyncClient(timeout=30.0) as client:
56
+ r = await client.post(f"{url}/webservice/rest/server.php", data=payload)
57
+ r.raise_for_status()
58
+ data = r.json()
59
+ if isinstance(data, dict) and data.get("exception"):
60
+ raise RuntimeError(
61
+ f"Moodle error: {data.get('message')} ({data.get('errorcode')})"
62
+ )
63
+ return data
64
+
65
+
66
+ @mcp.tool()
67
+ async def site_info() -> dict:
68
+ """Return Moodle site info and the authenticated user's id/username.
69
+
70
+ Use this first to verify the token works.
71
+ """
72
+ return await _call("core_webservice_get_site_info")
73
+
74
+
75
+ @mcp.tool()
76
+ async def list_my_courses() -> list[dict]:
77
+ """List courses the authenticated user is enrolled in.
78
+
79
+ Returns id, shortname, fullname, category id, and visibility.
80
+ """
81
+ me = await _call("core_webservice_get_site_info")
82
+ courses = await _call("core_enrol_get_users_courses", userid=me["userid"])
83
+ return [
84
+ {
85
+ "id": c["id"],
86
+ "shortname": c.get("shortname"),
87
+ "fullname": c.get("fullname"),
88
+ "category": c.get("category"),
89
+ "visible": c.get("visible", 1),
90
+ }
91
+ for c in courses
92
+ ]
93
+
94
+
95
+ @mcp.tool()
96
+ async def get_course_contents(course_id: int) -> list[dict]:
97
+ """Return sections, modules and file URLs for a course.
98
+
99
+ File URLs returned in `contents[].fileurl` require the Moodle token
100
+ appended as `?token=...` (or use download_file).
101
+
102
+ Args:
103
+ course_id: numeric course id (see list_my_courses)
104
+ """
105
+ return await _call("core_course_get_contents", courseid=course_id)
106
+
107
+
108
+ @mcp.tool()
109
+ async def search_courses(query: str, page: int = 0, perpage: int = 20) -> dict:
110
+ """Search the public course catalog by keyword.
111
+
112
+ Args:
113
+ query: search text
114
+ page: 0-indexed page
115
+ perpage: results per page (max 100)
116
+ """
117
+ return await _call(
118
+ "core_course_search_courses",
119
+ criterianame="search",
120
+ criteriavalue=query,
121
+ page=page,
122
+ perpage=perpage,
123
+ )
124
+
125
+
126
+ @mcp.tool()
127
+ async def list_assignments(course_ids: list[int] | None = None) -> dict:
128
+ """List assignments across given courses (or all enrolled if omitted).
129
+
130
+ Args:
131
+ course_ids: optional list of course ids; omit to use all enrolled
132
+ """
133
+ if course_ids is None:
134
+ me = await _call("core_webservice_get_site_info")
135
+ courses = await _call("core_enrol_get_users_courses", userid=me["userid"])
136
+ course_ids = [c["id"] for c in courses]
137
+ return await _call("mod_assign_get_assignments", courseids=course_ids)
138
+
139
+
140
+ @mcp.tool()
141
+ async def upcoming_events(limit: int = 20) -> dict:
142
+ """Return upcoming calendar events (deadlines, sessions) for the user.
143
+
144
+ Args:
145
+ limit: max number of events to return
146
+ """
147
+ return await _call(
148
+ "core_calendar_get_action_events_by_timesort", limitnum=limit
149
+ )
150
+
151
+
152
+ @mcp.tool()
153
+ async def get_user_grades(course_id: int) -> dict:
154
+ """Return the authenticated user's grades for a course.
155
+
156
+ Args:
157
+ course_id: numeric course id
158
+ """
159
+ me = await _call("core_webservice_get_site_info")
160
+ return await _call(
161
+ "gradereport_user_get_grade_items",
162
+ courseid=course_id,
163
+ userid=me["userid"],
164
+ )
165
+
166
+
167
+ @mcp.tool()
168
+ async def download_file(file_url: str, save_path: str) -> dict:
169
+ """Download a Moodle file (from get_course_contents) to a local path.
170
+
171
+ The Moodle token is appended automatically. The target directory is
172
+ created if missing.
173
+
174
+ Args:
175
+ file_url: pluginfile.php URL from a module's `contents[].fileurl`
176
+ save_path: absolute local path to write the file to
177
+ """
178
+ _, token = _config()
179
+ sep = "&" if "?" in file_url else "?"
180
+ url = f"{file_url}{sep}token={token}"
181
+ dest = Path(save_path).expanduser()
182
+ dest.parent.mkdir(parents=True, exist_ok=True)
183
+ async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
184
+ r = await client.get(url)
185
+ r.raise_for_status()
186
+ dest.write_bytes(r.content)
187
+ return {"saved": str(dest), "bytes": len(r.content)}
188
+
189
+
190
+ def main() -> None:
191
+ mcp.run()
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()