An Open edX plugin that exposes staff / superuser admin operations as a REST facade for an MCP (Model Context Protocol) server — so an AI agent (Claude, etc.) can run course, user, role, enrollment, certificate, report and authoring tasks against your LMS.
Pure Open edX: every operation calls native openedx-platform Python APIs.
No Hasura, no external identity, no multi-tenant org model. Authorization is the
platform's own is_staff / is_superuser, re-checked live on every request. MCP
keys are Django models administered from the standard Django admin.
Release: Ulmo. Pairs with
tutor-contrib-openedxmcp, which runs the MCP server and installs this app into the LMS/CMS.
Installs via the standard Open edX djangoapp plugin entry points — no core fork:
lms.djangoapp: openedx_mcp = openedx_mcp.apps:MCPLmsConfig -> ^api/mcp/
cms.djangoapp: openedx_mcp = openedx_mcp.apps:MCPCmsConfig -> ^api/mcp/cms/
Two mounts because course-authoring writes the modulestore (CMS-only) while
people/access/analytics run in the LMS. One app, one MCPKey table, shared.
With Tutor:
# tutor-contrib-openedxmcp auto-installs this app (Dockerfile patch); to pin:
# tutor config save --append OPENEDX_EXTRA_PIP_REQUIREMENTS=openedx-mcp==<ver>
tutor images build openedx
tutor local launch
tutor local do init # runs migrations -> creates MCPKey tablesOr plain pip into the openedx venv, then manage.py migrate openedx_mcp.
Django admin → Open edX Admin MCP → MCP keys → Add. Pick the acting user
(must be is_staff/is_superuser), a name, tick the scopes, save. The raw key is
shown once in the success banner — copy it — with ready-to-paste connect steps.
Revoke any time via the row action.
Streamable-http at https://mcp.<LMS_HOST>/mcp; authenticate with the raw key as
a Bearer token.
Claude Code (CLI):
claude mcp add --transport http openedx https://mcp.<LMS_HOST>/mcp \
--header "Authorization: Bearer <YOUR_MCP_KEY>"Claude Desktop (claude_desktop_config.json) — needs the mcp-remote bridge;
put the header value in env (mcp-remote splits args on spaces):
{
"mcpServers": {
"openedx": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.<LMS_HOST>/mcp",
"--header", "Authorization:${AUTH}"],
"env": { "AUTH": "Bearer <YOUR_MCP_KEY>" }
}
}
}First call the whoami tool — confirms identity + granted scopes.
- Authentication: an
X-MCP-Key(anMCPKeyrow) or the platform's own JWT bearer / session (an Open edX JWT already encodesis_staff/is_superuser). - Authorization: one live gate — the acting user must be
is_stafforis_superuserright now, re-checked every request. Demote them and every key they hold dies on the next call. - Scopes only ever narrow per key (least privilege); a superuser bypasses narrowing. See the table below.
- Write rails (
guards.audited_write): per-tool rate limit, dry-run/confirm- token handshake for high-blast-radius/destructive tools, and an append-onlyMCPAuditLogwritten before the mutation.destructiveis additive — a destructive tool needs its domain scope anddestructive.
Production: the confirm-token store and rate-limit counter use the ambient Django cache — point it at a shared backend (Redis/memcached). Under LocMemCache the limits multiply per worker and tokens are per-process.
| Scope | Gates |
|---|---|
read |
whoami, analytics, listings, roles, grades, cert/report status |
write:enrollment |
enroll, unenroll, bulk_enroll |
write:users |
create_user, reset_student_attempts |
write:roles |
set_role (course roles), instructor_access |
grant:admin |
set_role for global_staff / superuser (escalation) |
write:certificates |
generate / regenerate certificates |
write:reports |
submit async reports (grade export, …) |
write:courses |
block CRUD, create_block_tree, update_course_settings |
destructive |
additive — deactivate_user, request_retirement, invalidate_certificate, delete block |
| Tool | Process | Scope | Native API |
|---|---|---|---|
whoami |
LMS | read | request.user |
analytics_overview |
LMS | read | CourseOverview + enrollment_counts |
list_courses / course_detail |
LMS | read | course_overviews |
list_users / user_roles / course_team |
LMS | read | auth.User, CourseAccessRole, course roles |
user_grade |
LMS | read | grades.api.CourseGradeFactory |
enroll / unenroll / bulk_enroll |
LMS | write:enrollment | enrollments.api, instructor.enrollment |
create_user |
LMS | write:users | student.helpers.do_create_account |
reset_student_attempts |
LMS | write:users | instructor.enrollment.reset_student_attempts |
set_role |
LMS | write:roles / grant:admin | student.roles, GlobalStaff |
instructor_access |
LMS | write:roles | instructor.access.allow/revoke_access |
deactivate_user |
LMS | write:users + destructive | User.is_active |
generate_certificate / regenerate_certificates |
LMS | write:certificates | certificates.api, instructor_task.api |
invalidate_certificate |
LMS | write:certificates + destructive | certificates.api.invalidate_certificate |
list_course_certificates / user_certificates |
LMS | read | GeneratedCertificate |
submit_report / report_tasks / report_downloads |
LMS | write:reports / read | instructor_task.api, ReportStore |
retirement_status / request_retirement |
LMS | read / write:users + destructive | UserRetirementStatus, retirement utils |
read_course_outline |
CMS | read | modulestore + create_xblock_info |
create_block / create_block_tree / update_block |
CMS | write:courses | xblock_storage_handlers |
publish_block / delete_block |
CMS | write:courses (+destructive) | modulestore().publish, _delete_item |
update_course_settings |
CMS | write:courses | CourseDetails / CourseGradingModel / CourseMetadata |
openedx_mcp/
├── models.py # MCPKey, MCPAuditLog, Scope
├── admin.py # key console (checkbox scopes, connect banner)
├── apps.py # lms.djangoapp + cms.djangoapp configs
├── native/ # thin wrappers over native openedx APIs (LMS + CMS)
└── api/mcp/
├── auth.py # X-MCP-Key + JWT/session, live is_staff/superuser gate
├── _rails.py # pure rails: confirm-token, rate-limit, redact (unit-tested)
├── guards.py # audited_write decorator (rails + audit)
├── views.py / tool_views.py / ops_views.py / authoring_views.py
└── urls.py (LMS) / cms_urls.py (CMS)
| Setting | Default | Meaning |
|---|---|---|
OPENEDX_MCP_DEFAULT_KEY_TTL_DAYS |
90 |
Auto-expiry for new keys (blank expiry). None = no expiry. |
OPENEDX_MCP_PUBLIC_URL |
"" |
Public MCP endpoint shown in the admin connect banner (the Tutor plugin sets it). |
Follows the Open edX cookiecutter-django-app convention (test_settings.py,
Makefile, tox.ini, requirements/), but the suite runs without booting the
platform — a sqlite test_settings plus a guarded apps.py let the models,
admin, auth and safety-rails be tested directly.
make test.requirements # pip install -r requirements/test.txt
make quality # ruff
make test # pytest — models, key auth, scopes, rails (sqlite)
# or: tox -e py312-django42 / tox -e qualityThe native/ wrappers call openedx-platform at runtime, so they're exercised
against a running devstack (in-container), not in the standalone suite:
# from a Tutor dev stack with this package mounted into the LMS/CMS
tutor dev exec lms bash -c "cd /mnt/openedx-mcp && DJANGO_SETTINGS_MODULE=test_settings pytest"See CHANGELOG.md · CONTRIBUTING.md · TODO.md.