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.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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. Two reads leave the same traces a browser would: opening a forum thread marks it read, and asking moodle_get_assignments for feedback logs a "feedback viewed" event your teachers can see (off by default). 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.

Recorded against a fake Merlin with invented data. Try it yourself in a few commands: examples/.
What it does
flowchart LR
A["MCP clients<br/>(Claude Code, Codex, Cursor)"] -->|"/mcp, stateless streamable HTTP"| S
B["Your automation<br/>(cron, n8n, scripts)"] -->|"/api/call, /api/file"| S
C["Your phone<br/>(approval buttons)"] -->|"/api/approve, /api/reject"| S
S["merlin-mcp<br/>Worker or Node<br/>bearer auth, your token added server-side"] -->|"POST /yyyy-yyyy/webservice/rest/server.php"| M["Merlin<br/>moodle.srce.hr"]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 per server process (file downloads included), 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 with | due dates; with |
| 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. Merlin tells you through a built-in notifier; you approve with one tap.
agent ─► moodle_send_message ─► pending send (random id + 192-bit key, 30 min expiry); the agent gets "pending_approval", never the key
server ─► your notifier (ntfy or webhook) ─► recipient + full text, 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 sentntfy (two lines). Install the ntfy app, subscribe to a long random topic, and set:
NOTIFY_NTFY_URL=https://ntfy.sh/<your-long-random-topic>
NOTIFY_NTFY_TOKEN=... # optional, for a protected or self-hosted serverThe notification shows the recipient and the full text with Send and Reject buttons that POST straight from the app.
Discord (or any webhook).
NOTIFY_WEBHOOK_URL=https://discord.com/api/webhooks/...
NOTIFY_WEBHOOK_FORMAT=discord # or "json" (the default) for n8n, Home Assistant, Make, your own scriptWebhook links cannot POST, so they open a confirm page (GET /api/approve/<id>?k=<key>) that shows the recipient, year, full text and expiry with one button. A GET never changes anything, so link previews and prefetchers are harmless. json posts {event, id, year, to, text, approve_url, reject_url, expires_at}; a short send_outcome event follows after you approve (sent, failed, or unclear).
On Workers set these as wrangler secret put (they contain secrets: the topic and webhook URL are credentials). On Node/Docker put them in merlin.env; behind a proxy also set MERLIN_PUBLIC_URL so the links point at your public address.
Your own relay still works. Anything that polls
GET /api/pendingwithRELAY_BEARERand shows two buttons thatPOSTis supported exactly as before. The send tool is offered whenRELAY_BEARERor a notifier is configured; with neither, nobody could approve, so it is left out.MERLIN_ENABLE_SEND=0also turns it off.What the channel sees. The notification carries the full message text and the approval links (the key is in the link). Whoever can read the topic or webhook can approve, so use a long random topic or a protected server, not a shared channel. Notification errors are logged without URLs, time out after 5 s, and never fail the request.
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
POSTto act, 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.GET /api/approve/<id>?k=<key>,GET /api/reject/<id>?k=<key>: no bearer. An HTML confirm page with onePOSTbutton; changes nothing. 404 for an unknown id or wrong key.POST /api/approve/<id>?k=<key>,POST /api/reject/<id>?k=<key>: no bearer. 200 with what happened, or 404, 409, 410, 429. These and/api/pendingsendCache-Control: no-storeandReferrer-Policy: no-referrer; the confirm page also sets a strictContent-Security-PolicyandX-Frame-Options: DENY.
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. You sign in yourself, in your own browser; nothing here sees your AAI password.
Make the link.
node scripts/get-token.mjs link --year 2026-2027(use the academic year you need). It prints alaunch.phplink.Open it and sign in. In Firefox, open DevTools first (F12, Network tab, tick "Persist logs"); in Chrome, tick "Preserve log". Open the link and sign in with AAI@EduHr if asked.
Copy the answer. At the end Moodle redirects to an address starting
merlin-token://token=(it may use another name, such asmoodlemobile://, if the site forces one). The browser can't open it: Firefox shows it in an error page, otherwise find thelaunch.phprequest in the Network tab and copy itsLocationheader.Save it.
node scripts/get-token.mjs save '<that whole address>' --year 2026-2027. It checks the answer matches your link and writesMOODLE_TOKENintomerlin.env(readable only by you, and gitignored). It never prints the token unless you add--print, and it drops the second "private token" Moodle sends along.Check it. Start the server and call
moodle_whoami: it should show your name for every year you use.
Problem | What to do |
No | Log in to Merlin in that browser first, then open the link again in the same browser. |
"Warning: this answer does not match the last link" | You copied an answer from an older link or another year. Make a new link and repeat. |
| The token expired or was reset. Repeat these steps. |
Works for one year only | Make a token per year and set |
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_messageand creates no database file.Listening address: the Node server listens on
127.0.0.1only unless you setHOST(the Docker image setsHOST=0.0.0.0).GET /healthzanswers without auth and without calling Moodle, for health checks.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. Set
MERLIN_PUBLIC_URL(for example behind a reverse proxy) so approval links use your public address; without it they point atlocalhost, never at whatever Host header a request carries.
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.
wrangler.tomldeclares an emptyMerlinMCPDurable Object class. It is unused (left over from before 0.4) and will be removed with adeleted_classesmigration.
Licence
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
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.
Skool MCP server for ChatGPT, Claude and any AI agent: list members, approve membership request, create post, reply to comment, send DM, get members, churn and MRR, create calendar event and add course lesson in your own Skool account. Cotlet works in the account through your Cloud Browser, a real Chrome where you sign in to Skool yourself; Cotlet does not store your password and you need no Skool API key. About the official Skool API: Skool has no public API and no developer docs (checked 10 Oct 2026). The only official connection is the Zapier plugin on the Pro plan, which sends new paid members and Membership Questions answers out and can invite a member or unlock a course. Setup and FAQ: https://cotlet.dev/integrations/skool-api-mcp-server
Email, calendar and contacts for AI agents: Gmail, Microsoft 365 and IMAP accounts behind one API.
PLATO (Pusan National University LMS) MCP server via Moodle Web Service API + ubboard scraping
Related MCP Servers
- 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
- AlicenseNot gradedqualityAmaintenanceEnables AI agents to sign into a real Moodle LMS and handle the student workflow end to end: browsing courses, schedules, forums and grades, converting assignments and attachments into markdown, and uploading and submitting work as drafts or finalized submissions. Ships as a single static binary with write actions confirmed by the user and quiz-taking deliberately excluded.29MIT