moodle-ai-mcp
Allows AI agents to connect to a Moodle instance and inspect its site identity, authenticated user and capabilities, visible courses, discoverable external functions with signatures, and installed H5P libraries with their content schemas, all in a read-only manner.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@moodle-ai-mcpwhat H5P content types are installed on our Moodle site?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
moodle-ai-mcp
An AI-native MCP control plane for Moodle.
An MCP client (Claude Code, ChatGPT, Cursor, or anything else that speaks the Model Context Protocol) connects to this server and gets structured, accurate answers about a real Moodle site: what it is, who the connection is authenticated as, what it is allowed to do, which of Moodle's external functions it can reach, and — the part that makes it more than a REST wrapper — exactly which H5P libraries the site has installed and what their content schemas are.
This is not a thin wrapper around Moodle REST. The long-term goal is a control plane an AI client can use to design and build entire courses safely. This repository currently contains the first foundation of that.
Current maturity: read-side foundation, plus ten narrow curated writes
Working today:
MCP server on stdio with twenty curated tools, built on the official MCP TypeScript SDK
A Moodle 5.2 local plugin (
local_aimcp) with twenty-eight external functions — eighteen read-declared and ten narrow writes — real capability enforcement and PHPUnit coverageA write-policy substrate every curated mutation passes through: plan and apply, optimistic concurrency against a locked row, durable idempotency and Moodle-native audit, all enforced inside Moodle — plus a cryptographic confirmation handshake enforced in the MCP server. Proven across four editing operations in three different tables — each with its own capability set — and six times more for writes that bring new rows into existence — a course section, and five activity types inside one: a Page, a URL resource, a File, a Folder, and a Text and media area
A secure asset substrate for the file-backed creations: bytes travel inline as strict canonical base64, are sized before anything decodes, digest-bound into the confirmation, re-hashed at apply on both sides of the boundary, passed fail-closed through the site's configured antivirus when the attempt is new (an exact retry of a committed creation returns its durable result without re-running limit or scan checks), and verified against the final Moodle file area from inside the write transaction — with no persistent staging anywhere (docs/ASSETS.md)
A capability-aware course read model: sections, activities, completion and grade configuration, reflecting what the authenticated identity may actually see rather than everything with a
hiddenflag attachedDynamic discovery of the external functions the authenticated service can reach, with lossless signature introspection
Dynamic discovery of installed H5P libraries and their real installed semantics, converted to JSON Schema with explicit notes for everything JSON Schema cannot express
Not built, on purpose:
broad course or activity CRUD — course creation, deleting or moving anything, and creating any activity outside the five fixed types above, each in one existing section
arbitrary Moodle external-function execution, and any generic write executor
remote fetching of file content, server-path ingestion, and archive extraction — file bytes enter inline and canonically encoded, or not at all
H5P authoring, and quiz or question-bank authoring
atomic Blueprint apply — applying is built and is resumable rather than atomic; several curated writes cannot be one transaction without replacing the write substrate
browser QA and learner simulation
a remote HTTP transport with OAuth, and hosting infrastructure
The four editing writes that do exist are narrow, curated and individually allowlisted; each edits named fields of one existing object. See "Limitations" below.
Related MCP server: Drupal Bridge MCP
Architecture
AI client --MCP/stdio--> apps/mcp-server (TypeScript, MIT)
|
| authenticated Moodle web service call
v
moodle/local/aimcp (Moodle plugin, GPL-3.0-or-later)
|
v
Moodle 5.2 core + H5P coreThe server owns the protocol, the tool surface, orchestration and schema
conversion. The plugin owns everything that only Moodle can answer: identity,
context, capabilities, the external-function registry and the H5P engine.
Orchestration never leaks into PHP, and the rule is that Moodle logic is not
reimplemented in TypeScript — when PHP can decide something authoritatively, the
server asks PHP. Every tool that carries a value sends it to Moodle at plan
time and keeps no local copy of the rule: moodle_course_update_summary sends
the proposed summary and format, moodle_section_update_name sends the proposed
name, and neither imposes a length limit of its own — JavaScript counts UTF-16
code units where Moodle counts code points, so a local check would be a
different rule wearing the same name.
Details, including why the tool surface is a curated handful rather than several hundred, are in docs/ARCHITECTURE.md.
Prerequisites
Node.js 24
Docker, with a Moodle 5.2 stack from moodle-docker
A Moodle web service token for a user authorised on an enabled external service
Local development
Full instructions: docs/LOCAL-DEV.md. The short version:
cd ~/DEV/moodle-ai/moodle-ai-mcp
# 1. Start the Moodle stack (installs the persistence override, mounts the plugin)
./scripts/stack.sh start
# 2. Register the plugin with Moodle
docker exec -u www-data -w /var/www/html moodle-ai-webserver-1 \
php admin/cli/upgrade.php --non-interactive
# 3. Attach the plugin's functions to your external service (idempotent)
docker exec -u www-data -w /var/www/html moodle-ai-webserver-1 \
php public/local/aimcp/cli/provision_service.php --service=moodle_ai_mcp_dev
# 4. Build and run the server
npm install
npm run build
./scripts/run-server.shThe database, moodledata and installed H5P libraries live in named Docker
volumes, so ./scripts/stack.sh recreate is safe. Only
./scripts/stack.sh reset destroys data, and it asks first. Back up any time
with ./scripts/backup.sh.
Credentials come from .env.local, which is a symlink to a file outside this
repository. .env* is gitignored; see docs/SECURITY.md.
Connecting an MCP client
claude mcp add moodle-ai --scope local -- \
/absolute/path/to/moodle-ai-mcp/scripts/run-server.shOr with the Inspector:
npx @modelcontextprotocol/inspector ./scripts/run-server.shTools
Tool | What it answers |
| What Moodle is this, who am I connected as, what can that identity do, what plugins and H5P are available. |
| Which courses exist and are visible to this identity, optionally searched. |
| The structure of one course: sections in order, activities in course-page order, completion configuration and grade item configuration. Omits what the caller may not see, gates course-management fields (raw availability rules, module ID numbers) behind Moodle's own editor capabilities, and says how much it withheld. |
| Which of Moodle's external functions can this connection reach, ranked by relevance. Discovered live, never from a built-in list. |
| The full signature of one function: Moodle's own parameter and return tree, plus generated JSON Schema and conversion notes. |
| Which H5P libraries are installed, at which exact versions, which are runnable content types, which are dependency-only, and which Moodle currently offers for authoring. |
| The installed semantics for one H5P library version, plus generated JSON Schema and notes for everything H5P expresses that JSON Schema cannot. |
| Writes. Replaces one course's summary. No length limit is imposed, because Moodle imposes none. The text format is Moodle's to accept or refuse, and it refuses at the planning step, so a format it could not render never receives a confirmation token. |
| Writes. Renames one course section. An empty name clears the custom name, so the format default is shown again. HTML is refused rather than stripped, and refused at the planning step, so an invalid name never receives a confirmation token. |
| Writes. Brings one course into line with a Course Blueprint. Plans on the first call and writes nothing; applies on the second, given the token that plan issued. One confirmation covers the whole change set, and no per-operation token is ever returned. Applying is not atomic — each change goes through the same curated write the individual tools use, one at a time, and if one fails the ones before it stay applied while the ones after are not attempted. Nothing is rolled back; re-running with the same token and key resumes where it stopped. |
| What a Course Blueprint currently means against a real course: which managed fields already match, which would change, and which cannot be acted on and why. Reads only — it applies nothing and issues no confirmation token. Refs are resolved solely from an explicit binding document, never from a name or a position, and the binding names the Moodle installation its ids came from so they cannot be replayed elsewhere. |
| Writes. Sets one activity's structural show/hide state on the course page, taking its calendar events and grade items with it, exactly as Moodle does. Showing an activity does not by itself make it reachable — availability restrictions, dates, groups, completion prerequisites and course visibility still apply. It cannot set stealth mode, and will clear it. Permission is checked on the activity itself, so a per-activity override applies. Refuses while the containing section is hidden, because Moodle owns activity visibility there and would undo the change — show the section first with |
| Writes. Sets one course section's structural show/hide state through Moodle's own section action, which cascades into the section's activities: hiding parks each activity's own intent in that activity's |
| Writes, and is one of the three tools that create — through the one shared creation path. Adds one new empty section to the END of one course. It appends only: it cannot insert at a position, cannot move, reorder or delete sections, and adds nothing inside the new section. There is no way to delete a section through this server, so it cannot be undone here. The position it lands at is reported and is not something to plan around — section numbers are positions and Moodle rewrites them; the returned id is what identifies the section afterwards. Refused when the format does not use sections, and refused when a section owned by another component is numbered above the ordinary ones, because appending there would make Moodle renumber existing sections. |
| Writes, and creates an ACTIVITY — one Page resource ( |
| Writes, and creates an ACTIVITY — one URL resource ( |
| Writes, and creates an ACTIVITY — one File resource ( |
| Writes, and creates an ACTIVITY — one Folder resource ( |
| Writes, and creates an ACTIVITY — one Text and media area ( |
| Writes, through the same single creation paths as the six creating tools. Creates the ONE object a |
Each of the twelve writing tools is two phases of one tool rather than two tools.
Called without a confirmation token it returns a plan and changes nothing;
called with the token that plan issued, plus an idempotency key, it applies. A
client cannot reach the apply phase without a token the plan phase gave it.
moodle_blueprint_apply is the same shape over a whole change set: one
confirmation for the set, and no per-operation token is ever returned.
Twelve mutating MCP tools is not twelve ways to mutate Moodle. There are ten
low-level curated operations — four that edit named fields of one existing
object (a course summary, a section name, an activity's visibility, a
section's visibility) and six that create one object (a section, a Page, a
URL resource, a File resource, a Folder, a Text and media area). Ten
of the mutating tools each expose exactly one of those operations; the other
two orchestrate subsets of them — moodle_blueprint_apply drives the four
editors and cannot reach any creation, moodle_course_provision drives the
six creations — and neither adds an operation of its own. Counting tools
counts surfaces;
counting operations counts ways Moodle can change.
A plan can go stale, expire or find its target ineligible later, but it is never knowingly invalid when issued: every tool that carries a value sends it to Moodle at plan time, so a value Moodle would refuse never receives a token. What counts as refusable is Moodle's answer and not this project's — a section name has a length limit and must be plain text, a course summary has no length limit at all, and only its format can be unsupported. A creation is the one plan that never reports "nothing to do": no observation of a course can say the request was already honoured, so it always issues a token, and "have I already done this" is answered by the idempotency key instead. Retrying a creation with the same key returns the object the first call made — a section identified by its database id, or an activity identified by its course module id — rather than making a second one. The id comes from a durable record written in the same transaction as the object, and the object is never re-found by name, URL, filename, content hash or position.
Eight are annotated readOnlyHint: true. The twelve writing tools are annotated
readOnlyHint: false and destructiveHint: true — MCP's false means the tool
"performs only additive updates", and replacing a summary, a name or a visibility
state is not additive. Adding a section or an activity arguably is,
and all six creations are
still annotated true: destructiveHint is the signal a host reads before
deciding whether to ask a human, and this server's whole design says a human
must be asked.
That is the protocol's question, and it is not the same as "can this be undone":
the inverse call does restore the previous value for a summary or a rename, and
does not for an activity that was in stealth mode, since this tool cannot express
stealth and so cannot put it back. No creation can be undone through
this server at all — there is no delete operation — so all six creations are
reversible: false even though their riskClass is write. The plan reports
this project's riskClass — destructive for the two visibility operations,
activity and section, write for the others — and warns in words before the
change is confirmed. reversible is
a property of the operation registry and is documented in docs/SECURITY.md; it
is not a field on the plan. All twenty return structuredContent
and a JSON text fallback on success; an error returns a structured JSON text
block instead.
There is deliberately no generic "call any Moodle function" tool, and no
generic write executor. Search and describe make the long tail discoverable and
report whether Moodle declares each function read or write; nothing executes
a function by name. Moodle's own write label is treated as a floor, not a
safety verdict — it covers editing a description and deleting a course alike.
Tests
npm --prefix apps/mcp-server run typecheck # TypeScript, strict
npm --prefix apps/mcp-server run test:unit # pure logic, no Moodle needed
npm --prefix apps/mcp-server run build
npm --prefix apps/mcp-server run test:integration # real Moodle + real MCP session
./scripts/lint-plugin.sh # php -l over the plugin
./scripts/check-plugin.sh # Moodle coding standard (moodle-cs)
./scripts/test-plugin.sh # PHPUnit inside the Moodle containerThe integration suite is not a mock: it spawns the built server as a child process, speaks MCP to it with the official SDK client, and asserts against the live site — including that the identity is the expected Moodle user and that no token appears in any output.
Limitations
moodle_activity_set_visibilityneeds a visible parent section. While a section is hidden Moodle forces every activity in it to hidden and restores the previous value when the section is shown again, so an activity-level change there would not last. The tool returnsunsupported_target_staterather than reporting a success a later section unhide would contradict. Show the section first withmoodle_section_set_visibility, then change the activity.Ten narrow writes. A course summary, a section name, an activity's visibility, a section's visibility (with Moodle's own cascade into that section's activities), appending one empty section to a course, and creating one activity of one of five fixed types — a Page, a URL resource, a File resource, a Folder, or a Text and media area — in one existing section. Nothing else: no course creation, no activity type beyond those five, no delete of anything, no enrol, grade, role, H5P or quiz writes, no editing of an activity once created, and no way to move or reorder a section or activity. The development fixture CLI also writes, but is not reachable from any MCP client or web service (see docs/SECURITY.md).
File content enters inline or not at all. The file-backed creations accept bytes only as strict canonical base64 in the request itself — 8 MiB per file decoded, 16 MiB per request decoded, 50 files, or tighter where the course caps uploads (the plan reports the effective bound). No URL is ever fetched, no server path is ever read, no archive is ever extracted, and nothing is staged between plan and apply: the confirmation binds each file's SHA-256 digest, and the apply re-derives and compares before anything is written. Larger or streaming uploads are a later transport milestone. docs/ASSETS.md has the full contract, including the honest limits: a failed apply can leave unreferenced content in Moodle's file pool (rows roll back; pool blobs do not), a transient draft copy lives in the acting user's draft area until Moodle's routine cleanup, and a site file policy such as the EXIF redactor may lawfully transform bytes on entry — the result reports stored digests beside confirmed ones.
Page creation processes files only when assets are supplied. Without
assetsthe content is stored exactly as sent, on the same path Moodle's own test generator uses, which processes no draft file area: a@@PLUGINFILE@@reference stays literal text and renders as a broken link — the original contract, unchanged. Withassets, the files land inmod_page's own content area and those references resolve through Moodle's normal file serving.URL creation stores the address and never visits it. No step fetches, resolves or health-checks the destination, so nothing on this surface can vouch for what is behind a stored URL — availability, safety, TLS or suitability for learners. The grammar is deliberately narrower than Moodle's own form: absolute
http:///https://only, stored byte for byte, with anything Moodle would rewrite refused rather than normalised.Nothing can be deleted, including anything this server created. There is no delete operation on this surface, so all six creating operations are irreversible here; removing what they made means doing it in Moodle. That is recorded as
reversible: falsein the operation registry and warned about on every plan and every result.Appending is refused when it would renumber. Moodle's own append stops being an append when a section owned by another component — a subsection, say — is numbered above the ordinary sections: core then inserts at the end and pulls the new section down into place, renumbering the existing ones. This operation declines that case rather than doing more than it planned.
No arbitrary external-function execution. Functions are discoverable and describable; none can be invoked by name.
Confirmation tokens are signed with a per-process key, so they do not survive an MCP server restart. Plan again after a restart — and note that a retry through the tool is then impossible, since a retry must present the same plan.
Idempotency guarantees at-most-once per (user, key), not exactly-once.
A request's identity includes the plan it was made from, so reusing a key across two plans is a conflict even when the requested content is identical.
Each write locks its own target row -
course,course_sectionsorcourse_modules- before re-reading and comparing state, so an ordinary Moodle edit cannot slip between the check and the write. There is deliberately no lock-any-table helper. Row locking is implemented for PostgreSQL, the MySQL family and SQL Server; only PostgreSQL is covered by tests.A retry reports recorded fingerprints and the current state separately; the original content is not stored, so it cannot be reported.
stdio only. HTTP transport is a future addition; the domain layer is already transport-free.
Course Blueprint: five managed fields, no structure.
domain/blueprint/defines the versioned desired-state document, its semantic refs, the separate binding document that maps a ref onto a Moodle id on one named site, and a live diff behindmoodle_blueprint_diff.moodle_blueprint_applyplans and applies.docs/BLUEPRINT.mddescribes all of it.A Blueprint can change a course summary and its format, a section name, a section's visibility (v1alpha2 documents only) and an activity's visibility, and nothing else. It creates nothing, deletes nothing, moves nothing and reorders nothing, and applying it drives the same curated writes the individual tools use — there is no generic write executor behind it. It cannot reach any of the six creating tools either: creation is explicit and direct, an unbound ref is a blocked document rather than an instruction to create the thing it names, and
docs/BLUEPRINT.mdsets out why that will not change for this document kind. Applying is not one transaction: each change is individually atomic and individually protected against being applied twice, the set is not, and nothing is rolled back if one fails.Bindings are hand-written input on every call. Nothing persists them, nothing derives them, and there is no fallback resolver — a ref with no binding is reported unresolved rather than guessed from a name, a number or a position. Because Moodle ids are local to one installation, a binding document records which installation it was captured against, and Moodle refuses the call before using any id if that is not the site it is talking to.
No content generation.
No browser automation, screenshots or accessibility auditing.
moodle_course_inspectreturns course structure, not learner performance: no grades and no per-user completion state.Moodle's front page is a course row but not a teaching course, so
moodle_course_inspectrejects it.moodle_course_liststill reports it, flaggedisSiteCourse.H5P schema generation is one level deep: a nested
libraryfield fixes the wrapper shape and the allowed library versions, but itsparamsfollow that library's own semantics — fetch them with a secondmoodle_h5p_schemacall.Some H5P and Moodle constructs cannot be expressed in JSON Schema (
showWhenconditions, HTML tag whitelists, PCRE patterns, PARAM cleaning rules). They are preserved asx-h5p-*/x-moodle-*annotations and reported as conversion notes rather than dropped.Moodle REST cannot express an empty array or a true
null; the client reports both as explicit warnings to the server log — they are not returned to the MCP client.The plugin is bind-mounted into the container from this repository; the rsync copy is kept only as a fallback. A host symlink does not work, for reasons explained in docs/LOCAL-DEV.md.
Licensing
apps/mcp-server/— MITmoodle/local/aimcp/— GPL-3.0-or-later (required: it is a Moodle plugin)
No GPL implementation code is copied into the MIT server. Reference projects were studied as architecture references and reimplemented clean-room; the reasoning, per project, is in docs/REFERENCE-ARCHITECTURE.md.
Documentation
docs/ARCHITECTURE.md — design and boundaries
docs/REFERENCE-ARCHITECTURE.md — reuse matrix and licensing
docs/LOCAL-DEV.md — reproducible local setup
docs/SECURITY.md — secret handling, authorisation, surface constraints
This server cannot be deployed
Maintenance
Related MCP Connectors
Read-only discovery of Framekeep capabilities, security boundaries, and public resources.
Find governed AI capabilities and verify signed receipts. Read-only, no account.
Provides metadata information to AI agents through the search API.
Read-only MCP server for turva.dev's published service catalog, pricing and contact details. Five tools return JSON, including dated agent-readiness and security evidence with verification links. Connect over Streamable HTTP without an API key. The server answers questions about turva.dev and does not scan other websites or run audits.
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables AI assistants to interact with Moodle via web services, allowing tasks like listing courses, assignments, events, and downloading files.104MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to interact with Drupal sites through MCP tools, with automatic discovery, OAuth-based authentication, and scope validation.6 npm5MIT
- AlicenseNot gradedqualityCmaintenanceConnects Moodle LMS with AI assistants through the Model Context Protocol, enabling users to interact with Moodle data via a conversational chatbot interface.7 npmMIT
- AlicenseAqualityCmaintenanceProvides read-only access to Gemini 3 Online's knowledge surface (models, pricing, links, FAQ) for MCP-compatible AI clients, requiring no API keys.3MIT