Skip to main content
Glama

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 coverage

  • A 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 hidden flag attached

  • Dynamic 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 core

The 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.sh

The 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.sh

Or with the Inspector:

npx @modelcontextprotocol/inspector ./scripts/run-server.sh

Tools

Tool

What it answers

moodle_site_inspect

What Moodle is this, who am I connected as, what can that identity do, what plugins and H5P are available.

moodle_course_list

Which courses exist and are visible to this identity, optionally searched.

moodle_course_inspect

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.

moodle_functions_search

Which of Moodle's external functions can this connection reach, ranked by relevance. Discovered live, never from a built-in list.

moodle_functions_describe

The full signature of one function: Moodle's own parameter and return tree, plus generated JSON Schema and conversion notes.

moodle_h5p_types

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.

moodle_h5p_schema

The installed semantics for one H5P library version, plus generated JSON Schema and notes for everything H5P expresses that JSON Schema cannot.

moodle_course_update_summary

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.

moodle_section_update_name

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.

moodle_blueprint_apply

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.

moodle_blueprint_diff

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.

moodle_activity_set_visibility

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 moodle_section_set_visibility.

moodle_section_set_visibility

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 visibleold, showing restores each activity from its own visibleold — so an activity whose remembered intent is hidden (hidden individually, or created while the section was hidden) STAYS hidden after the show. The one confirmed field is the section's visibility; descendant post-state is Moodle's cascade, not a promised set of activity writes. Requires BOTH moodle/course:update AND moodle/course:sectionvisibility. Refuses the general section (section 0) and delegated sections. A rename does not stale a pending plan; a visibility change does. Classified destructive: hide-then-show is not guaranteed to reproduce the exact prior descendant picture.

moodle_course_add_section

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.

moodle_section_add_page

Writes, and creates an ACTIVITY — one Page resource (mod_page) in one existing section the caller names by id. It is specifically Page creation, not generic module creation. WITHOUT assets it behaves exactly as it always has: no file upload, content stored verbatim, @@PLUGINFILE@@ references left as literal text. WITH assets, the supplied files (inline strict base64, digest-bound into the confirmation) land in the page's own content area so @@PLUGINFILE@@/path/filename references resolve through Moodle's normal file serving — the content itself is still stored verbatim. The result identifies the page by its cmId (the course module id — the value a binding document records), reports the page instance id separately as an observation, and a retry returns the same cmId from a durable receipt rather than re-finding the page by name or position. The page inherits its section's visibility, exactly as one added through Moodle's own form. There is no way to edit, move or delete an activity through this server, so it cannot be undone here. The idempotency key is issued by the plan and the confirmation is bound to it, so one confirmation creates at most one page.

moodle_section_add_url

Writes, and creates an ACTIVITY — one URL resource (mod_url) in one existing section the caller names by id. It is specifically URL creation, not generic module creation, and the address is stored, never fetched: nothing here can say whether the destination exists, is safe or is appropriate for learners. Only absolute http:///https:// addresses with a non-empty host component (a syntactic check — DNS is never consulted) and no embedded credentials are accepted, stored byte for byte — whitespace or HTML entities Moodle would strip or decode are refused with the value Moodle would store, a missing scheme is refused outright, and nothing is silently normalised. The result identifies the resource by its cmId; the mod_url instance id is an observation, and the URL string is payload, never an identity — two resources with the same address are two resources. The resource inherits its section's visibility, and its appearance settings are the site's mod_url defaults read at apply time. There is no way to edit, move or delete an activity through this server. The idempotency key is issued by the plan and the confirmation is bound to it, so one confirmation creates at most one resource.

moodle_section_add_file

Writes, and creates an ACTIVITY — one File resource (mod_resource) in one existing section, storing ONE supplied file. The file travels inline as strict canonical base64 — no URL is ever fetched, no server path is ever read, no archive is extracted — and its SHA-256 digest is bound into the confirmation, so the bytes applied are provably the bytes planned; they are re-hashed at apply on both sides of the boundary, and a new apply passes the site's configured antivirus fail-closed before anything is written — an exact retry of a committed apply returns the durable result without re-running limit or scan checks. Limits are discoverable on the plan (8 MiB per file or the course's tighter cap). The result identifies the resource by its cmId; the instance id, filename and content digest are observations — two resources holding byte-identical files are two resources, and nothing deduplicates by content. Appearance settings are the site's mod_resource defaults read at apply time. No edit, move or delete exists on this surface.

moodle_section_add_folder

Writes, and creates an ACTIVITY — one Folder resource (mod_folder) in one existing section, storing the supplied files under their logical paths. Subdirectories are first-class — the stored hierarchy is exactly the stated paths — and duplicate logical locations are refused, never merged. Same byte discipline as the File resource: inline strict base64, digest-bound confirmation, apply-time re-hash and antivirus, in-transaction verification of the final file area, and NO archive extraction — a ZIP is stored as a single file. At least one file; limits discoverable on the plan (8 MiB per file, 16 MiB per request, 50 files). Displays on its own page, never inline. Identified by its cmId. No way to add or remove files after creation — state the complete hierarchy up front.

moodle_section_add_text_media

Writes, and creates an ACTIVITY — one Text and media area (mod_label) in one existing section. The content renders DIRECTLY on the course page, stored raw exactly as the form stores it — trusted, teacher-authored HTML. The title is optional, exactly as on Moodle's own form: omitted, Moodle derives one from the first characters of the text and the result reports it. Optional embedded files (inline strict base64, area root only, digest-bound) land in the module's intro area so @@PLUGINFILE@@/filename references resolve; without assets the content is stored verbatim with no file processing. Identified by its cmId; results truthfully report the module as label.

moodle_course_provision

Writes, through the same single creation paths as the six creating tools. Creates the ONE object a CourseProvisioning document states — one section, one Page activity (with optional embedded assets at v1alpha4), one URL resource, one File resource, one Folder, or one Text and media area (v1alpha1 states exactly one section; v1alpha2 adds the Page; v1alpha3 adds the URL resource; v1alpha4 adds the File, the Folder, the Text and media area and the Page's optional assets — one creation total at every version) — on the course its bindings name, under a semantic ref the author chooses — and returns the binding delta (ref + created section id, or ref + created cmId) the caller merges into their own bindings file. The ref must be NEW: a ref the bindings already bind is refused, and an unbound ref in an ordinary CourseBlueprint still blocks — creation is never inferred from an absence in either direction. For an activity, the parent sectionRef must already be bound (a missing binding never creates the section too), and the plan proves the bound section belongs to the bound course before any token exists. The idempotency key is issued by the plan and the confirmation is bound to it, so one confirmation creates at most one object. Nothing is persisted server-side; see docs/PROVISIONING.md.

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 riskClassdestructive 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 container

The 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_visibility needs 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 returns unsupported_target_state rather than reporting a success a later section unhide would contradict. Show the section first with moodle_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 assets the 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. With assets, the files land in mod_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: false in 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_sections or course_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 behind moodle_blueprint_diff. moodle_blueprint_apply plans and applies. docs/BLUEPRINT.md describes 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.md sets 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_inspect returns 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_inspect rejects it. moodle_course_list still reports it, flagged isSiteCourse.

  • H5P schema generation is one level deep: a nested library field fixes the wrapper shape and the allowed library versions, but its params follow that library's own semantics — fetch them with a second moodle_h5p_schema call.

  • Some H5P and Moodle constructs cannot be expressed in JSON Schema (showWhen conditions, HTML tag whitelists, PCRE patterns, PARAM cleaning rules). They are preserved as x-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/ — MIT

  • moodle/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

Related MCP Connectors

Related MCP Servers