PluginWorld
Me

merlin-mcp

MCP

Enables AI agents to read a user's SRCE Moodle account data, including grades, deadlines, calendar events, course files, forum posts, and messages, with optional private-message sending gated by phone approval. It can run as a Cloudflare Worker or self-hosted via Node or Docker.

@simonseifert · v0.4.0 · MIT License · updated today

SECURITY

B

SCORE

68

STARS

▲ 0

PLUG IN

git clone https://github.com/simonseifert/merlin-mcp.git

See the README to configure this MCP server

README

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.

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

SIMILAR PLUGINS