Merlin MCP
TL;DR: A read-only bridge from your own Merlin account (SRCE's Moodle for Croatian universities, Moodle 5.2) to AI agents over the Model Context Protocol. 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 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 andMOODLE_URLpins one year. - Polite by design. An honest
User-Agent(add your contact withMOODLE_CONTACT), the token in a POST body rather than the URL, at most 120 Moodle calls a minute, and a pause forRetry-Afterwhen Merlin answers 429 or 503. - Code layout:
src/app.tsis the platform-neutral core (routes, auth, MCP tools, Moodle client).src/index.tsis the Cloudflare entry,src/node.tsthe Node entry (HTTP or stdio),src/approvals.tsthe pending-send store,src/lib.tsthe pure helpers (years, auth compare, the allow-list, redaction, HTML stripping). Tests are intest/, an agent skill inskills/merlin/, client snippets in 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/pendingand show two buttons works: an ntfy script, a Telegram bot, Pushover. WithoutRELAY_BEARERthe tool isn't offered at all, because nobody could approve it.MERLIN_ENABLE_SEND=0also 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 toyear="all": every active year in parallel, returning{years, items, errors?}withyearon every item. One failing year goes undererrorsand 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'syear, or find it themselves. An id that exists in more than one year returns an error asking foryear. - 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_TOKENSto a JSON map{"2025-2026": "...", "2026-2027": "..."}; a missing year falls back toMOODLE_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_ALLOWLISTinsrc/lib.ts). Anything else is a 400 and never reaches Moodle. Callers can't override the token or format,core_message_get_messagesalways hasmarkasread=0, and credential fields (including keys embedded intokenpluginfile.phpURLs) are stripped from responses.GET /api/notifications?unread_only=true&limit=20[&year=]: a JSON array of raw notification objects.year=allmerges 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_BEARERonly): the last 24 h of send requests withapprove_url/reject_urlwhile 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/pendingsendCache-Control: no-storeandReferrer-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), 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
invalidtokenerror means you need a fresh one.
Run it
Cloudflare Worker
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).
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_URLso 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. Load the agent skill in skills/merlin/ 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:
"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
MerlinMCPDurable Object class from before 0.4, so rollbacks keep working. It will be removed with adeleted_classesmigration.