Metadata-Version: 2.4
Name: totara-mcp
Version: 0.2.0
Summary: MCP server that wraps Totara external GraphQL
Author: Naveed Masood Butt
Author-email: Naveed Masood Butt <naveed.masood@arbisoft.com>
License-Expression: MIT
Requires-Dist: fastmcp>=4.1.1,<5
Requires-Dist: httpx>=0.28.1,<1
Requires-Python: >=3.15
Description-Content-Type: text/markdown

# Totara MCP server

A [FastMCP](https://gofastmcp.com/) 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.

```mermaid
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](https://docs.astral.sh/uv/) (`uvx` can fetch Python 3.15) |
| **Developers** (this repo) | Docker and Docker Compose |

## End users (published package)

Install [uv](https://docs.astral.sh/uv/), then copy [examples/cursor-mcp.json](examples/cursor-mcp.json) into `~/.cursor/mcp.json` (or merge the `totara` entry into an existing file):

```json
{
  "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

```bash
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

```bash
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/mcp.json). Cursor starts the container with Compose and speaks stdio. Totara credentials come from `.env` via Compose; the committed file has no secrets.

```json
{
  "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:

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

Sample result:

```json
{
  "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:

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

Sample result:

```json
{
  "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:

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

Sample result:

```json
{
  "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`):

```bash
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**:

```bash
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](https://pypi.org/) account, enable 2FA, and create an API token
2. Optional dry run against TestPyPI:

```bash
uv build
uv publish --publish-url https://test.pypi.org/legacy/
```

3. Production:

```bash
uv build
uv publish
```

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