Merlin MCP
Read-only bridge to a personal Merlin (SRCE's Moodle 5.2) account, exposing tools to list enrolled courses, notifications, calendar events, upcoming deadlines, course contents and files, recent updates, forum discussions and posts, grades, assignments with submission status, quiz attempts and reviews, and private message threads. Handles Moodle's per-academic-year sites (querying multiple years in parallel and collapsing duplicate copies), strips HTML from responses, and normalizes timestamps to Zagreb local time. Includes an optional, phone-approved single write action for sending private messages, plus bearer-gated REST routes for raw read-only WebService calls, notifications, and file proxying.
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., "@Merlin MCPWhat are my upcoming deadlines this week?"
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.
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.
Related MCP server: moodle-mcp
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 |
| core_webservice_get_site_info | token sanity check |
| core_enrol_get_users_courses | enrolled courses |
| message_popup_get_popup_notifications | bell-icon notifications |
| core_calendar_get_calendar_events | sessions, deadlines and exams in a window |
| core_calendar_get_action_events_by_timesort | action-required items, sorted by due time |
| core_course_get_contents | sections and modules of one course |
| core_course_get_contents | every file with size, modified time and a ready |
| core_course_get_updates_since | cheap "what changed in the last N hours" |
| mod_forum_get_forum_discussions | latest discussions in a forum |
| mod_forum_get_discussion_posts | full text of one thread (marks it read, like a browser) |
| gradereport_user_get_grade_items | grades and feedback, for one course or all |
| mod_assign_get_assignments + mod_assign_get_submission_status | due dates, submission status and grader comments |
| mod_quiz_get_user_attempts | your attempts at a quiz |
| mod_quiz_get_attempt_review | per-question marks and feedback of a finished attempt |
| core_message_get_conversations / core_message_get_conversation_messages | private-message inbox, or one full thread |
| core_message_send_instant_messages, only after phone approval | write, gated, optional: creates a pending DM and returns |
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 sentThe 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 deployMake 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-mcpstdio 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.
Licence
This server cannot be deployed
Maintenance
Related MCP Connectors
Remote streamable-HTTP MCP server running on a single Cloudflare Worker. Your assistant gets live Airbnb, Amazon, Booking.com, Google Flights, Maps and Reddit data, social search on X, Instagram and TikTok, the Meta Ad Library, and image/video generation without any keys. Connect your own accounts to let it send WhatsApp or Telegram messages, work an IMAP inbox, manage Meta Ads campaigns and publish to X and LinkedIn. OAuth 2.1 with PKCE; stored credentials are AES-256-GCM encrypted.
- WorkwaeOAuthcom.workwae
Email, calendar and contacts for AI agents: Gmail, Microsoft 365 and IMAP accounts behind one API.
Hosted Google Calendar MCP server for AI agents. No self-hosting or Google Cloud setup.
9 remote MCP servers on Cloudflare Workers for AI agents. Free tier + Pro API keys.
Related MCP Servers
- AlicenseBqualityCmaintenanceExposes Moodle data (courses, users, enrollments, grades, SCORM, completion reports) as tools for Claude via stdio or HTTP transport.71 npmMIT
- FlicenseNot gradedqualityBmaintenanceA Model Context Protocol (MCP) server that connects AI coding agents to your Moodle LMS. Fetch assignments, grades, deadlines, and sync everything to Obsidian automatically.-
- FlicenseNot gradedqualityCmaintenanceEnables AI agents to interact with Moodle LMS, fetching assignments, grades, deadlines, course content, and syncing to Obsidian, with WhatsApp alerts and class-slot filtering.-
- AlicenseAqualityCmaintenanceEnables AI agents to access a student's EPFL Moodle data—deadlines, course files, assignments, forums, and grades—through read-only tools.12MIT