Skip to main content
Glama
README.md
# 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](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.

## 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](docs/ARCHITECTURE.md).

## Prerequisites

- Node.js 24
- Docker, with a Moodle 5.2 stack from
  [moodle-docker](https://github.com/moodlehq/moodle-docker)
- A Moodle web service token for a user authorised on an enabled external
  service

## Local development

Full instructions: [docs/LOCAL-DEV.md](docs/LOCAL-DEV.md). The short version:

```bash
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](docs/SECURITY.md).

## Connecting an MCP client

```bash
claude mcp add moodle-ai --scope local -- \
  /absolute/path/to/moodle-ai-mcp/scripts/run-server.sh
```

Or with the Inspector:

```bash
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 `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

```bash
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](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](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](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](docs/REFERENCE-ARCHITECTURE.md).

## Documentation

- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — design and boundaries
- [docs/REFERENCE-ARCHITECTURE.md](docs/REFERENCE-ARCHITECTURE.md) — reuse matrix and licensing
- [docs/LOCAL-DEV.md](docs/LOCAL-DEV.md) — reproducible local setup
- [docs/SECURITY.md](docs/SECURITY.md) — secret handling, authorisation, surface constraints