Skip to main content

Totara MCP server

A FastMCP server that lets Cursor (and other MCP clients) call Totara 20 external GraphQL only. It exposes 24 tools covering users, courses, enrolment, audiences, jobs, hierarchy, programs, certifications, and seminars. Legacy Totara REST (/webservice/rest/server.php) and course creation are out of scope.

The server speaks stdio only. Cursor starts a local process and talks to it over stdin/stdout. There is no remote HTTP MCP endpoint.

flowchart LR
  subgraph developers [Developers in this repo]
    CursorDev[Cursor] -->|stdio| ComposeRun["docker compose run totara-mcp"]
    ComposeRun -->|OAuth GraphQL| TotaraDev[Totara site]
  end
  subgraph endUsers [End users]
    CursorUser[Cursor] -->|stdio| Uvx["uvx totara-mcp"]
    Uvx -->|OAuth GraphQL| TotaraUser[User Totara site]
  end

Requirements

  • A Totara 17+ site with an OAuth 2.0 client credentials client under Quick-access menu → Development → API
  • The OAuth client must be linked to a Totara user who holds the capabilities those GraphQL operations enforce (list/create users, enrol, audiences, jobs, hierarchy, programs, certifications, seminars, and so on)

Plus, depending on how you run the server:

Audience Needs
End users uv (uvx can fetch Python 3.15)
Developers (this repo) Docker and Docker Compose

End users (published package)

Install uv, then copy examples/cursor-mcp.json into ~/.cursor/mcp.json (or merge the totara entry into an existing file):

{
  "mcpServers": {
    "totara": {
      "type": "stdio",
      "command": "uvx",
      "args": ["totara-mcp", "--transport", "stdio"],
      "env": {
        "TOTARA_BASE_URL": "https://your-totara.example.com",
        "TOTARA_CLIENT_ID": "your-client-id",
        "TOTARA_CLIENT_SECRET": "your-client-secret"
      }
    }
  }
}
  1. Replace the three env values with your Totara site URL and OAuth client credentials
  2. Reload MCP (Customize → MCPs) and confirm the totara server shows green

uvx totara-mcp installs the package from PyPI and can download Python 3.15 to satisfy requires-python >= 3.15. You do not need Docker or this git clone.

Developers (this repo)

Python, uv, and package dependencies stay inside the image. The host only needs Docker.

Configure

cp .env.example .env

Edit .env (never commit it):

Variable Purpose
TOTARA_BASE_URL Totara site root URL, no trailing slash e.g. http://demo-totara-txp.totara84.localhost
TOTARA_CLIENT_ID OAuth client id from Development → API
TOTARA_CLIENT_SECRET OAuth client secret

Auth flow used by the server:

  1. POST {TOTARA_BASE_URL}/totara/oauth2/token.php with grant_type=client_credentials
  2. POST {TOTARA_BASE_URL}/api/graphql.php with Authorization: Bearer …

Build once

docker compose build

Rebuild after Dockerfile or uv.lock changes. Do not put --build in Cursor MCP config; Cursor restarts the process often.

Cursor

This repo ships .cursor/mcp.json. Cursor starts the container with Compose and speaks stdio. Totara credentials come from .env via Compose; the committed file has no secrets.

{
  "mcpServers": {
    "totara": {
      "type": "stdio",
      "command": "docker",
      "args": [
        "compose",
        "-f",
        "${workspaceFolder}/docker-compose.yml",
        "run",
        "-i",
        "-T",
        "--rm",
        "totara-mcp",
        "totara-mcp",
        "--transport",
        "stdio"
      ]
    }
  }
}
  1. Fill in .env and run docker compose build
  2. Open this project as the Cursor workspace
  3. Reload MCP (Customize → MCPs) and confirm the totara server shows green

While this workspace is open, the project .cursor/mcp.json wins over any totara entry in ~/.cursor/mcp.json, so you exercise this checkout, not the last published PyPI version.

Tools

Each tool sends one custom GraphQL document to /api/graphql.php and returns a short JSON object. GraphQL errors (often still HTTP 200) become a tool error. HTTP 401 refreshes the OAuth token once and retries that call once.

User, course, and id/idnumber arguments follow the same rule as enrol: provide exactly one identifier of that kind. Paginated list tools return {items, total, next_cursor} (except totara_list_seminars, which has no cursor). Lookup tools that expose found return found: false and a null record when Totara has no match.

totara_list_courses

Calls Totara query core_course_courses.

Argument Required Description
limit no Page size (default 20)
cursor no Opaque next_cursor from a previous page
since_timemodified no Filter: unix timestamp, numeric string, or ISO-8601 date

Sample call:

{
  "limit": 20,
  "cursor": null,
  "since_timemodified": null
}

Sample result:

{
  "items": [
    {
      "id": "2",
      "fullname": "Introduction",
      "shortname": "intro",
      "idnumber": "C1"
    }
  ],
  "total": 1,
  "next_cursor": ""
}

totara_create_user

Calls Totara mutation core_user_create_user. Other profile fields (address, custom fields, tenant, and so on) are intentionally not exposed.

The MCP server always sends generate_password: true. Totara creates a random password and emails it to the user. Callers never supply a password.

username, email, firstname, and lastname are required to create the user, but they are optional on the tool call. Pass a field only when the user explicitly supplied it. Do not invent a username, email, or name. If any of those four is omitted, the tool asks the user (MCP elicitation) before calling Totara. If the client cannot show that form, the tool returns an error listing the missing fields and does not create a user. Declining or cancelling the form also creates no user.

Argument Required Description
username yes, asked if omitted Unique username. Do not invent one
email yes, asked if omitted Unique email. Do not invent one
firstname yes, asked if omitted First name. Do not invent one
lastname yes, asked if omitted Last name. Do not invent one
idnumber no External id number. Omit unless the user supplied it
auth no Auth plugin shortname (for example manual). Omit unless the user supplied it

Sample call:

{
  "username": "learner1",
  "email": "learner1@example.com",
  "firstname": "Lea",
  "lastname": "Ner",
  "auth": "manual"
}

Sample result:

{
  "id": "10",
  "username": "learner1",
  "email": "learner1@example.com",
  "firstname": "Lea",
  "lastname": "Ner",
  "idnumber": null
}

totara_enrol_user

Calls Totara mutation enrol_manual_enrol_user.

Provide exactly one user identifier and exactly one course identifier. Omit role_shortname to use the enrol instance default (usually Learner).

Argument Required Description
user_id one of user_* User database id (string or integer)
user_username one of user_* Username
user_idnumber one of user_* User idnumber
user_email one of user_* User email
course_id one of course_* Course database id (string or integer)
course_shortname one of course_* Course shortname
course_idnumber one of course_* Course idnumber
role_shortname no Role shortname (for example student)

Sample call:

{
  "user_username": "learner1",
  "course_shortname": "intro",
  "role_shortname": "student"
}

Sample result:

{
  "success": true,
  "was_already_enrolled": false,
  "user": {
    "id": "10",
    "username": "learner1",
    "idnumber": null,
    "email": "learner1@example.com"
  },
  "course": {
    "id": "2",
    "shortname": "intro",
    "idnumber": "C1"
  },
  "role": {
    "id": "5",
    "shortname": "student"
  }
}

Additional tools

Tool GraphQL operation Identifiers / filters Result
totara_list_users core_user_users limit, cursor, since_timemodified, since_timecreated, status (ACTIVE/SUSPENDED/ALL) Page of id, username, email, firstname, lastname, idnumber, suspended
totara_get_user core_user_user_v2 One user_* found + user fields or null
totara_is_user_enrolled core_course_is_user_enrolled One user_* + one course_* user_currently_enrolled
totara_unenrol_user enrol_manual_unenrol_user One user_* + one course_* success, user_had_manual_enrolment, short user/course
totara_get_course_completion core_completion_get_course_completion One user_* + one course_* Short user/course + status, progress, timecompleted
totara_list_audiences core_cohort_static_cohorts limit, cursor, since_timemodified Page of id, name, idnumber, active, members_count
totara_is_audience_member core_cohort_is_user_member One user_* + one of audience_id / audience_idnumber user_is_member
totara_add_audience_member core_cohort_add_cohort_member Same as membership check success, was_already_member, short user/audience
totara_list_job_assignments totara_job_job_assignments limit, cursor, since_timemodified Page of job + nested user/position/organisation
totara_get_job_assignment totara_job_job_assignment One of job_id, job_idnumber, or one user_* found + job or null
totara_list_organisations hierarchy_organisation_organisations limit, cursor, name, since_timemodified Page of id, fullname, shortname, idnumber, visible
totara_get_organisation hierarchy_organisation_organisation One of organisation_id / organisation_idnumber found + organisation or null
totara_list_positions hierarchy_position_positions limit, cursor, name, since_timemodified Same shape as organisations
totara_get_position hierarchy_position_position One of position_id / position_idnumber found + position or null
totara_list_programs totara_program_programs_v2 limit, cursor, since_timemodified Page of id, fullname, shortname, idnumber, visible
totara_get_program totara_program_program_v2 One of program_id / program_idnumber found + program or null
totara_list_program_groups totara_program_groups limit, cursor, optional program id/idnumber Page of id, name, can_self_enrol, can_self_unenrol
totara_list_certifications totara_certification_certifications_v2 limit, cursor, since_timemodified Same short fields as programs
totara_get_certification totara_certification_certification_v2 One of certification_id / certification_idnumber found + certification or null
totara_list_certification_groups totara_certification_groups limit, cursor, optional certification id/idnumber Same group fields as programs
totara_list_seminars mod_facetoface_seminars_in_course One course_*, optional since_timemodified {items, total} of id, name, shortname (no cursor)

Write tools (totara_unenrol_user, totara_add_audience_member) should only run after the user asked for that change. Never invent identifiers.

Try it (Inspector)

Over stdio (developers, after docker compose build):

npx @modelcontextprotocol/inspector \
  docker compose -f docker-compose.yml run -i -T --rm totara-mcp totara-mcp --transport stdio

Exercise tools in this order:

  1. totara_list_courses — confirm courses come back
  2. totara_create_user — create a disposable test user
  3. totara_enrol_user — enrol that user into a listed course
  4. Call totara_enrol_user again with no user identifier — confirm the validation error path

Do not point Inspector at a production Totara site while experimenting with create/enrol.

(npx is only for the optional Inspector UI. The MCP server itself does not require Node on the host.)

Tests

Run the test suite inside the container:

docker compose --profile test run --rm --build test

Tests mock Totara over HTTP. They do not call a live site. Coverage includes: OAuth token request and cache, GraphQL document/variables for each tool, GraphQL errors → tool error, and one refresh-and-retry on HTTP 401.

Publish (PyPI)

Package name on PyPI: totara-mcp. The GitHub repo can stay private; PyPI only receives the built sdist and wheel.

  1. Create a PyPI account, enable 2FA, and create an API token
  2. Optional dry run against TestPyPI:
uv build
uv publish --publish-url https://test.pypi.org/legacy/
  1. Production:
uv build
uv publish
  1. Verify on a machine without this checkout: uvx totara-mcp --help

Later releases: bump version in pyproject.toml (PyPI never overwrites an existing version), then build and publish again. Totara credentials are never part of the package; they stay in each user’s mcp.json or .env.

Layout

totara-mcp/
  pyproject.toml
  uv.lock
  LICENSE
  Dockerfile              # runtime + test image targets
  docker-compose.yml      # totara-mcp (stdio) + test profile
  .env.example
  .cursor/mcp.json        # developers: Compose stdio
  examples/
    cursor-mcp.json       # end users: copy into ~/.cursor/mcp.json
  README.md
  src/totara_mcp/
    server.py             # FastMCP app; stdio
    config.py             # Environment settings
    oauth.py              # Client-credentials token cache
    graphql.py            # POST /api/graphql.php + 401 retry
    refs.py               # Shared reference and pagination builders
    tools.py              # register_tools → operations.register_all
    operations/           # All MCP tools by domain (courses, users, …)
  tests/
    test_oauth.py
    test_tools.py
    test_operations.py

Metadata

Release files for totara-mcp 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for totara-mcp 0.2.0
File Size Uploaded Provenance
totara_mcp-0.2.0.tar.gz 18.0 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for totara-mcp 0.2.0
File Interpreter ABI Platform
totara_mcp-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 44.0 kB

Release files / totara_mcp-0.2.0.tar.gz

Download URL totara_mcp-0.2.0.tar.gz
Size 18.0 kB
Tags Source
SHA-256 checksum
How to use checksums
c5bdab5c40b2ad9c6d03175f5ccea8f13d905c16658d2084b00b0c911c0b5350
BLAKE2b-256 checksum
How to use checksums
2d83ca5b29e535b15c470753e3a80c6126747da12c4d9bef4d46f6eb61b286d4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / totara_mcp-0.2.0-py3-none-any.whl

Download URL totara_mcp-0.2.0-py3-none-any.whl
Size 25.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cc2ab8dc7494874726673c7dc6dc8f7c2a65f4b106b2bbfb09ea1ab7d2c3524f
BLAKE2b-256 checksum
How to use checksums
92cf85b48e9931fb4459260088730d607fa4b2da1d056dc27fbd708a6cbce70b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for totara-mcp 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page