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.
- mcp_moodle-0.1.0/.env.example +2 -0
- mcp_moodle-0.1.0/.github/workflows/release.yml +31 -0
- mcp_moodle-0.1.0/.gitignore +10 -0
- mcp_moodle-0.1.0/LICENSE +21 -0
- mcp_moodle-0.1.0/PKG-INFO +155 -0
- mcp_moodle-0.1.0/README.md +128 -0
- mcp_moodle-0.1.0/pyproject.toml +43 -0
- mcp_moodle-0.1.0/src/moodle_mcp/__init__.py +0 -0
- mcp_moodle-0.1.0/src/moodle_mcp/get_token.py +393 -0
- mcp_moodle-0.1.0/src/moodle_mcp/server.py +195 -0
|
@@ -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
|
mcp_moodle-0.1.0/LICENSE
ADDED
|
@@ -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()
|