Skip to main content
Glama

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 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.

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

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 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), 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

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_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. 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 MerlinMCP Durable Object class from before 0.4, so rollbacks keep working. It will be removed with a deleted_classes migration.

Licence

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    C
    maintenance
    Exposes Moodle data (courses, users, enrollments, grades, SCORM, completion reports) as tools for Claude via stdio or HTTP transport.
    7
    1 npm
    MIT
  • F
    license
    Not graded
    quality
    B
    maintenance
    A Model Context Protocol (MCP) server that connects AI coding agents to your Moodle LMS. Fetch assignments, grades, deadlines, and sync everything to Obsidian automatically.
    -
  • A
    license
    A
    quality
    C
    maintenance
    Enables AI agents to access a student's EPFL Moodle data—deadlines, course files, assignments, forums, and grades—through read-only tools.
    12
    MIT