Skip to main content
Glama
README.md
# Merlin MCP

**TL;DR:** A read-only bridge from your own [Merlin](https://moodle.srce.hr) account (SRCE's Moodle for Croatian universities, Moodle 5.2) to AI agents over the [Model Context Protocol](https://modelcontextprotocol.io). It runs as a Cloudflare Worker or self-hosted on Node or Docker, from the same code. It serves MCP on `/mcp` and a few bearer-gated REST routes for automation. It never writes to Merlin, with one optional, gated exception: private messages, which go out only after you tap **Send** on your phone. (Reading a forum thread marks it read, exactly as opening it in a browser does.) One server holds one person's token; don't host other people's.

> Unofficial. Not affiliated with or endorsed by SRCE or any university. Use it only with your own account, follow your institution's rules (AAI@EduHr included), and keep the call volume low. See [SECURITY.md](SECURITY.md) for the security model.

## What it does

```
MCP clients (Claude Code, Codex, Cursor, ...) ──► /mcp (stateless streamable HTTP) ─┐
your automation (cron, n8n, scripts)          ──► /api/call, /api/file, ...        ├─► Worker or Node ─► moodle.srce.hr/<yyyy-yyyy>/webservice/rest
your phone (approval relay buttons)           ──► /api/approve, /api/reject         ─┘   (bearer auth)       (your token added server-side)
```

- **Every active academic year at once.** Merlin runs one Moodle site per academic year (`https://moodle.srce.hr/<start>-<end>`), each with its own database, so ids are only unique within a year. The server reads the current year (rolls over on 1 October, Europe/Zagreb) plus the previous one, plus next year from 1 September once its site answers. `MERLIN_YEARS="2025-2026,2026-2027"` overrides the list and `MOODLE_URL` pins one year.
- **Polite by design.** An honest `User-Agent` (add your contact with `MOODLE_CONTACT`), the token in a POST body rather than the URL, at most 120 Moodle calls a minute, and a pause for `Retry-After` when Merlin answers 429 or 503.
- **Code layout:** `src/app.ts` is the platform-neutral core (routes, auth, MCP tools, Moodle client). `src/index.ts` is the Cloudflare entry, `src/node.ts` the Node entry (HTTP or stdio), `src/approvals.ts` the pending-send store, `src/lib.ts` the pure helpers (years, auth compare, the allow-list, redaction, HTML stripping). Tests are in `test/`, an agent skill in `skills/merlin/`, client snippets in [CLIENTS.md](CLIENTS.md).

## MCP tools

All tools except `moodle_send_message` are read-only, and all take an optional `year` (see "Multiple years"). Output is compact JSON: HTML stripped, noise fields dropped, a Zagreb-local string (`*_local`) next to every timestamp. Results are capped at 200k characters by dropping whole trailing items, so the output is always valid JSON; a capped result says `truncated: true` and gives `omitted_items`.

| Tool | Moodle function(s) | What it's for |
|---|---|---|
| `moodle_whoami` | core_webservice_get_site_info | token sanity check |
| `moodle_get_courses` | core_enrol_get_users_courses | enrolled courses |
| `moodle_get_notifications` | message_popup_get_popup_notifications | bell-icon notifications |
| `moodle_get_calendar_events` | core_calendar_get_calendar_events | sessions, deadlines and exams in a window |
| `moodle_get_upcoming_deadlines` | core_calendar_get_action_events_by_timesort | action-required items, sorted by due time |
| `moodle_get_course_contents` | core_course_get_contents | sections and modules of one course |
| `moodle_list_files` | core_course_get_contents | every file with size, modified time and a ready `/api/file?url=…` path |
| `moodle_get_updates_since` | core_course_get_updates_since | cheap "what changed in the last N hours" |
| `moodle_get_recent_forum_posts` | mod_forum_get_forum_discussions | latest discussions in a forum |
| `moodle_get_forum_discussion` | mod_forum_get_discussion_posts | full text of one thread (marks it read, like a browser) |
| `moodle_get_grades` | gradereport_user_get_grade_items | grades and feedback, for one course or all |
| `moodle_get_assignments` | mod_assign_get_assignments + mod_assign_get_submission_status | due dates, submission status and grader comments |
| `moodle_get_quiz_attempts` | mod_quiz_get_user_attempts | your attempts at a quiz |
| `moodle_get_quiz_attempt_review` | mod_quiz_get_attempt_review | per-question marks and feedback of a finished attempt |
| `moodle_get_messages` | core_message_get_conversations / core_message_get_conversation_messages | private-message inbox, or one full thread |
| `moodle_send_message` | core_message_send_instant_messages, only after phone approval | **write, gated, optional**: creates a pending DM and returns `pending_approval`; nothing is sent until you tap Send. Offered only when `RELAY_BEARER` is set |

## Sending a message (phone approval)

The model can only *request* a send. It never receives the approval key, and no bearer-authenticated route can send.

```
agent ─► moodle_send_message ─► pending send (random id + 192-bit key, 30 min expiry); the agent gets "pending_approval", never the key
your relay (polls GET /api/pending with RELAY_BEARER) ─► pushes recipient + full text to your phone with [Send] [Reject]
your phone ─► POST /api/approve/<id>?k=<key>  ─► the server sends exactly the stored text
           └► POST /api/reject/<id>?k=<key>   ─► marked rejected, nothing sent
```

- **The relay is yours to run.** Anything that can poll `/api/pending` and show two buttons works: an [ntfy](https://ntfy.sh) script, a Telegram bot, Pushover. Without `RELAY_BEARER` the tool isn't offered at all, because nobody could approve it. `MERLIN_ENABLE_SEND=0` also turns it off.
- **Limits.** Recipients must be people you already have a Merlin conversation with. Plain text, at most 1800 characters. At most 10 sends per Zagreb day (a failed send gives its slot back) and 5 undecided requests at once. Each link works once, needs `POST` (so link previews can't approve), and expires after 30 minutes.
- **Why.** Merlin messages and forum posts are untrusted input. A prompt injection in one of them must not be able to make the agent send anything.

## Multiple years

- **List tools** (`moodle_get_notifications`, `moodle_get_calendar_events`, `moodle_get_upcoming_deadlines`, `moodle_get_courses`, `moodle_get_messages`, `moodle_get_grades`, `moodle_get_assignments`, `moodle_whoami`) default to `year="all"`: every active year in parallel, returning `{years, items, errors?}` with `year` on every item. One failing year goes under `errors` and the others still come back.
- **Id tools** (`moodle_get_course_contents`, `moodle_list_files`, `moodle_get_updates_since`, `moodle_get_recent_forum_posts`, `moodle_get_quiz_attempts`, `moodle_get_quiz_attempt_review`, `moodle_get_forum_discussion`) take the id's `year`, or find it themselves. An id that exists in more than one year returns an error asking for `year`.
- **Copies from the yearly clone.** Each new year's site starts as a copy of the last, so older notifications and messages exist in both. Copies are collapsed on (id + time + subject) or (id + members), never on id alone, and the kept copy lists the other years in `also_in`.
- **Tokens.** One token usually works on both sites because the clone copies it. If they ever differ, set `MOODLE_TOKENS` to a JSON map `{"2025-2026": "...", "2026-2027": "..."}`; a missing year falls back to `MOODLE_TOKEN`.

## REST endpoints

`/api/*` takes `Authorization: Bearer <API_BEARER>`. `/mcp` takes `MCP_BEARER`. Neither opens the other (unless you set `LEGACY_API_BEARER="1"` for an old single-bearer setup). The approval routes have their own credentials.

- `GET /api/call?wsfunction=<fn>[&year=]&<params>`: a read-only passthrough for an exact, reviewed list of 19 functions (`API_CALL_ALLOWLIST` in `src/lib.ts`). Anything else is a 400 and never reaches Moodle. Callers can't override the token or format, `core_message_get_messages` always has `markasread=0`, and credential fields (including keys embedded in `tokenpluginfile.php` URLs) are stripped from responses.
- `GET /api/notifications?unread_only=true&limit=20[&year=]`: a JSON array of raw notification objects. `year=all` merges every active year.
- `GET /api/file?url=<https://moodle.srce.hr/...>`: a file proxy. The token is added server-side; redirects are refused, not followed.
- `GET /api/pending` (`RELAY_BEARER` only): the last 24 h of send requests with `approve_url` / `reject_url` while pending.
- `POST /api/approve/<id>?k=<key>`, `POST /api/reject/<id>?k=<key>`: no bearer. 200 with what happened, or 404, 409, 410, 429, 405. These and `/api/pending` send `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.

## Getting your token

The server needs a Moodle web-service token for your account. On Merlin you sign in through AAI@EduHr, so `login/token.php` with a password doesn't work. Use the mobile-app sign-in that Moodle documents (`admin/tool/mobile/launch.php`, [source](https://github.com/moodle/moodle/blob/v5.2.2/public/admin/tool/mobile/launch.php)), the same flow the MerlinMobile app uses. Things to know:

- **It's the same token the official app uses.** Moodle hands back your existing token, so every client shares one. If it's ever reset, all of them stop working at once.
- **Treat it like a password.** Students usually can't revoke their own token on Merlin, so a leaked one stays valid until it expires or SRCE revokes it. Never commit it, paste it in chats, or put it in a shared server.
- **It expires.** Moodle's default lifetime is 12 weeks (sites can change it). An `invalidtoken` error means you need a fresh one.

## Run it

**Cloudflare Worker**

```bash
npm install
npm run typecheck && npm test
npx wrangler secret put MOODLE_TOKEN      # and MCP_BEARER, API_BEARER; optionally RELAY_BEARER, MOODLE_CONTACT, MOODLE_TOKENS
npx wrangler deploy
```

Make bearers with `openssl rand -hex 32`. `npm run dev` runs it locally from a gitignored `.dev.vars`.

**Node or Docker** (needs Node 22.13+). Configuration is environment variables with the same names (`merlin.env.example`). Pending sends live in SQLite (`MERLIN_DB`, default `./merlin.sqlite`).

```bash
npm ci && npm run build:node
MOODLE_TOKEN=... MCP_BEARER=... API_BEARER=... node dist/node.js   # HTTP on :8787
MOODLE_TOKEN=... node dist/node.js --stdio                          # one local client, read-only

docker build -t merlin-mcp .
docker run --env-file merlin.env -p 8787:8787 -v merlin-data:/data merlin-mcp
```

- **stdio is always read-only:** it never offers `moodle_send_message`.
- **Reachability:** a server on your LAN or tailnet works for CLI agents. claude.ai and ChatGPT connect from their own clouds and need a public HTTPS URL plus OAuth, which this server doesn't have yet. Behind a reverse proxy, set `MERLIN_PUBLIC_URL` so approval links use the public address.

After a deploy, check: `moodle_whoami` answers for each year, `tools/list` returns the tools, and `/api/call?wsfunction=core_webservice_get_site_info` works with the API bearer and fails (401) with the MCP bearer.

## Connecting a client

Snippets for Claude Code, Codex, Gemini CLI, Cursor, Hermes, OpenClaw, n8n and stdio are in [CLIENTS.md](CLIENTS.md). Load the agent skill in [skills/merlin/](skills/merlin/SKILL.md) alongside, so the agent knows which tool answers what and the safety rules.

For Claude Code, keep the bearer out of `~/.claude.json` with a `headersHelper` that reads it from a mode-600 file:

```json
"merlin": {
  "type": "http",
  "url": "https://<your-worker>.workers.dev/mcp",
  "headersHelper": "/path/to/print-merlin-header.sh"
}
```

where the script prints `{"Authorization": "Bearer <MCP_BEARER>"}`.

## Notes

- If Merlin's admins disable a function, the tool that uses it returns an error.
- Moodle errors come back as tool errors or 502s, with the token redacted.
- The Worker still declares an empty `MerlinMCP` Durable Object class from before 0.4, so rollbacks keep working. It will be removed with a `deleted_classes` migration.

## Licence

[MIT](LICENSE)