Skip to main content
Glama
README.md
# ohneben's LearnWorlds MCP

[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-ohneben-FFDD00?style=for-the-badge&logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/ohneben)

---

#### License & checks

[![CI](https://github.com/ohneben/Learnworlds-MCP/actions/workflows/ci.yml/badge.svg)](https://github.com/ohneben/Learnworlds-MCP/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE.md)

#### MCP registries

[![MCP Registry](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fregistry.modelcontextprotocol.io%2Fv0.1%2Fservers%2Fio.github.ohneben%252Flearnworlds-mcp%2Fversions%2Flatest&query=%24.server.version&prefix=v&label=MCP%20Registry&color=blue&logo=modelcontextprotocol&logoColor=white)](https://registry.modelcontextprotocol.io/v0.1/servers/io.github.ohneben%2Flearnworlds-mcp/versions/latest)
[![Listed on mcpservers.org](https://mcpservers.org/badge.svg)](https://mcpservers.org/servers/ohneben/learnworlds-mcp)
[![Learnworlds-MCP MCP server](https://glama.ai/mcp/servers/ohneben/Learnworlds-MCP/badges/score.svg)](https://glama.ai/mcp/servers/ohneben/Learnworlds-MCP)

Run your [LearnWorlds](https://www.learnworlds.com/) school in plain language from AI
assistants like **Claude**, **Cursor**, and any other
[MCP](https://modelcontextprotocol.io) client.

This [Model Context Protocol](https://modelcontextprotocol.io) server exposes the
**LearnWorlds public API** β€” all **94 endpoints**, generated straight from the
OpenAPI spec into MCP tools. Every tool is **safety-categorized**
(🟒 read-only / 🟑 write / πŸ”΄ destructive) so your assistant knows what an action does
*before* it calls it. It runs over **stdio** (Claude Desktop and other local launchers)
or **Streamable HTTP** (hosted in Docker), and ships with retries, client-side rate
limiting, and request timeouts so it holds up against a live school.

## Why you'll want this

Some MCP servers just forward an API. This one is built to be **safe to hand to an
LLM** and **easy to run for real**:

| What you get | Why it matters |
| --- | --- |
| **All 94 endpoints, spec-driven** | Full coverage of courses, users, enrollments, payments, subscriptions, coupons, certificates, seats, community and reporting β€” nothing hand-picked or left behind. |
| **Every tool is safety-categorized** 🟒 / 🟑 / πŸ”΄ | A banner at the top of each tool description tells the model exactly what it does β€” read, create, update or delete β€” before it acts. |
| **Descriptions written for agents, not humans** | Every tool spells out its side effects, auth and rate-limit behavior, return shape, error codes, when *not* to reach for it, and which sibling tools are the alternatives. |
| **Machine-readable MCP annotations** (`readOnlyHint`, `destructiveHint`) | Hosts that honor annotations (Claude included) can auto-trust reads and demand confirmation before anything destructive. |
| **Automatic retries with backoff** | Transient `429` / `5xx` responses are retried with jittered exponential backoff, honoring the server's `Retry-After` header. |
| **Built-in rate limiting** | Self-throttles under LearnWorlds' **30 requests / 10 s** cap so a burst of tool calls never trips a `429`. |
| **Per-request timeouts** | A hung upstream call is aborted and retried instead of freezing the server. |
| **Two transports: stdio *and* Streamable HTTP** | Use it locally in Claude Desktop, or run one always-on server that any number of MCP clients reach over HTTP. |
| **Docker + docker-compose, health check, auto-restart** | Production-style deployment out of the box: `docker compose up` and it stays up. |
| **Bearer-token auth** on the HTTP endpoint | Required as soon as the server is bound beyond loopback: without `MCP_AUTH_TOKEN` it refuses to start rather than exposing the API unprotected. |
| **Your secrets never reach the model** | Credentials live in the server's environment and are injected on every request β€” the assistant only ever sees tool inputs and API responses. |
| **Drop-in spec updates** | LearnWorlds ships a newer YAML? Replace one file and rebuild β€” new endpoints become new tools automatically, no code changes. |

### How it compares

At the time of writing this appears to be the only dedicated LearnWorlds MCP server.
You *could* instead point a generic OpenAPI→MCP wrapper at the spec — here's what
that leaves on the table:

| Capability | **This project** | Generic OpenAPI→MCP wrapper\* |
| --- | :---: | :---: |
| All 94 LearnWorlds endpoints as tools | βœ… | βœ… |
| Per-tool 🟒 / 🟑 / πŸ”΄ safety category + banner | βœ… | ❌ |
| `readOnlyHint` / `destructiveHint` MCP annotations | βœ… | βž– |
| `$ref` dereferencing + recursion-safe schemas | βœ… | βž– |
| Automatic retries on `429` / `5xx` (honors `Retry-After`) | βœ… | ❌ |
| Client-side rate limiting (stays under 30 req / 10 s) | βœ… | ❌ |
| Per-request timeout with abort | βœ… | βž– |
| `stdio` transport | βœ… | βœ… |
| **Streamable-HTTP transport** | βœ… | βž– |
| **Docker + docker-compose**, health check, auto-restart | βœ… | ❌ |
| **Enforced bearer-token auth** on the endpoint | βœ… | ❌ |
| Credentials injected server-side, never sent to the model | βœ… | βž– |
| License | MIT | varies |

<sub>\*Generic OpenAPI→MCP wrappers turn any Swagger/OpenAPI spec into MCP tools. They
can reach the same endpoints, but treat every operation identically β€” no safety
categories, no resilience, no deployment story, and no guardrails tuned for live
school data. "βž–" = varies by tool / not guaranteed. Snapshot from July 2026.</sub>

## What you can do

Once it's connected, ask your assistant things like:

- "List the 10 most recent users who signed up this month."
- "Create a user for jane@example.com and enroll her in the 'Pro' bundle."
- "Show me this month's payments and total revenue."
- "Which users haven't completed the 'Onboarding' course yet?"
- "Create a 20%-off coupon for the annual subscription plan."
- "Pull completion analytics for our top 5 courses."

Tools are generated automatically from the API and grouped into 🟒 read-only,
🟑 write, and πŸ”΄ destructive β€” so a well-behaved host can treat each group differently.

## How it works

```
Claude / Cursor / any MCP client  ──MCP──►  this server  ──HTTPS──►  LearnWorlds API (your school)
```

The server parses the bundled OpenAPI spec into MCP tools (resolving `$ref`s and
guarding against recursive schemas), tags each with its safety category, and injects
your bearer token and `Lw-Client` header on every outgoing request. Your credentials
stay in the server's environment β€” the model never sees or handles them.

## Requirements

- A **LearnWorlds school with API access** β€” an **access token** and a **Client ID**
  (admin β†’ Settings β†’ Integrations β†’ Developers), plus your school's API base URL.
  See [Get your API credentials](#get-your-api-credentials).
- **Docker** (Docker Desktop on macOS/Windows) for the quick start below β€” or
  **Node.js β‰₯ 18** to [run from source](#run-from-source-stdio-no-docker).

## Quick start (Docker)

**1. Add your credentials.** Copy the example config and fill it in:

```bash
cp .env.example .env
# edit .env β†’ set LEARNWORLDS_BASE_URL, LEARNWORLDS_API_TOKEN, LEARNWORLDS_CLIENT_ID
#           β†’ set MCP_AUTH_TOKEN (required once the port is published beyond
#             127.0.0.1): openssl rand -hex 32
```

**2. Start the server:**

```bash
docker compose up -d --build
```

The bundled `docker-compose.yml` binds to `127.0.0.1:8765` only, so the server is
reachable from your machine but not the network.

**3. Confirm it's running:**

```bash
curl -s http://localhost:8765/health
# β†’ {"status":"ok","server":"learnworlds-mcp","tools":94}
```

**4. Connect your MCP client.** The MCP endpoint is `http://localhost:8765/mcp`.

- **Claude Desktop** β€” add a **custom connector** (Settings β†’ Connectors) pointing at
  the URL, or bridge it locally with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote).
  Add this under `mcpServers` in your config, then fully quit and reopen the app:

  ```json
  {
    "mcpServers": {
      "learnworlds": {
        "command": "npx",
        "args": [
          "mcp-remote",
          "http://localhost:8765/mcp",
          "--header", "Authorization: Bearer YOUR_MCP_AUTH_TOKEN"
        ]
      }
    }
  }
  ```

  (Drop the `--header` line if you left `MCP_AUTH_TOKEN` empty.)

- **Claude Code** β€” one command:

  ```bash
  claude mcp add --transport http learnworlds http://localhost:8765/mcp
  ```

- **Claude Cowork** β€” shares Claude Code's MCP config, so the command above makes the
  tools available there too.

### Prefer a prebuilt image?

Every push to `main` publishes a ready-to-run image to the GitHub Container Registry,
so you can skip the local build entirely:

```bash
docker run -d --name learnworlds-mcp -p 127.0.0.1:8765:8765 --env-file .env \
  ghcr.io/ohneben/learnworlds-mcp:latest
```

## Get your API credentials

1. Log in to your LearnWorlds school admin.
2. Go to **Settings β†’ Integrations β†’ Developers** (the API screen).
3. Copy your **Access Token** β†’ `LEARNWORLDS_API_TOKEN`, and your **Client ID** β†’
   `LEARNWORLDS_CLIENT_ID`.
4. Set `LEARNWORLDS_BASE_URL` to your school's API base:
   `https://<your-school>.learnworlds.com/admin/api`. If your school uses a custom
   domain, use that host instead (e.g. `https://academy.example.com/admin/api`).

Put all three in `.env`. The server injects them on every request, so your assistant
never sees them.

## Configuration

Everything is set in `.env` (copied from `.env.example`):

| Variable | Required | Default | Description |
|---|---|---|---|
| `LEARNWORLDS_BASE_URL` | βœ… | β€” | Your school's API base URL |
| `LEARNWORLDS_API_TOKEN` | βœ… | β€” | Bearer access token |
| `LEARNWORLDS_CLIENT_ID` | βœ… | β€” | Sent as the `Lw-Client` header |
| `MCP_TRANSPORT` | β€” | `stdio` | `stdio` or `http` (the Docker image defaults to `http`) |
| `PORT` | β€” | `8765` | HTTP listen port |
| `HOST` | β€” | `0.0.0.0` | HTTP bind address |
| `MCP_HTTP_PATH` | β€” | `/mcp` | HTTP MCP route |
| `MCP_AUTH_TOKEN` | ⚠️ | _(off)_ | Require `Authorization: Bearer <token>` on `/mcp`. **Required** when `HOST` is not a loopback address, otherwise the server refuses to start. Renamed from `MCP_SHARED_TOKEN` in 1.1.0 |
| `MCP_ALLOWED_HOSTS` | β€” | _(automatic)_ | Comma-separated hostnames accepted in the `Host` header (DNS-rebinding protection). Needed behind a reverse proxy |
| `MCP_ALLOW_INSECURE` | β€” | _(off)_ | Lifts the startup refusal. Only for an endpoint nobody else can reach |
| `MCP_SESSION_TTL` | β€” | `1800` | Idle seconds before a session is dropped |
| `MCP_MAX_SESSIONS` | β€” | `256` | Maximum concurrent sessions |
| `MCP_BODY_LIMIT` | β€” | `25mb` | Largest accepted request body |
| `LEARNWORLDS_MAX_REQUESTS` | β€” | `25` | Client-side requests per window (`0` disables throttling) |
| `LEARNWORLDS_RATE_WINDOW_MS` | β€” | `10000` | Rate-limit window in ms |
| `LEARNWORLDS_MAX_RETRIES` | β€” | `3` | Retries on `429` / `5xx` / network errors |
| `LEARNWORLDS_TIMEOUT_MS` | β€” | `30000` | Per-attempt request timeout |
| `LEARNWORLDS_OPENAPI_PATH` | β€” | _(bundled)_ | Load a different OpenAPI YAML |

After changing `.env`, reload with `docker compose up -d --force-recreate`.

## Tool safety categories

Each tool's description starts with one of these banners and carries the matching
[MCP annotations](https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations):

| Banner | Count | `readOnlyHint` | `destructiveHint` | Meaning |
|---|:---:|:---:|:---:|---|
| 🟒 **READ-ONLY** | 59 | `true` | `false` | Fetches data only. Safe. |
| 🟑 **WRITE Β· creates data** | β€” | `false` | `false` | `POST` β€” creates records (not idempotent; may duplicate). |
| 🟑 **WRITE Β· updates data** | β€” | `false` | `false` | `PUT` β€” modifies existing records in place (idempotent). |
| πŸ”΄ **DESTRUCTIVE Β· deletes** | 8 | `false` | `true` | `DELETE` β€” removes a record. Confirm first. |

The 🟑 write tools total **27** (17 create + 10 update). Hosts that respect annotations
(Claude included) can require confirmation for `destructiveHint` tools and trust
`readOnlyHint` tools automatically.

Under the banner, every description follows the same layout so an agent can act on the
first read instead of probing:

| Line | What it answers |
|---|---|
| *(purpose)* | What the endpoint does, straight from the OpenAPI spec, stated once. |
| **Behavior** | What the call does to the live school, whether it is idempotent, and how auth, throttling and retries are handled for it. |
| **Parameters** | Only what the input schema cannot say β€” the `body` wrapper, paging, and any renamed argument. |
| **Returns** | The `HTTP <status>` + JSON result shape and the error codes that can come back. |
| **Use when** | When to reach for it, when not to, and up to five sibling tools in the same area. |

> Run `npm run list-tools` (no credentials needed) to print the full catalog and the
> per-category counts at any time.

<details>
<summary><strong>Coverage by area (read / write / delete)</strong></summary>

| Area | 🟒 Read | 🟑 Write | πŸ”΄ Delete |
|---|:---:|:---:|:---:|
| Users | 10 | 7 | 1 |
| Affiliates | 7 | 1 | 0 |
| Community | 6 | 3 | 2 |
| Courses | 5 | 3 | 0 |
| Promotions (coupons) | 4 | 3 | 0 |
| Reporting | 4 | 0 | 0 |
| Payments | 3 | 0 | 0 |
| Multiple seats | 3 | 3 | 2 |
| User groups | 3 | 3 | 2 |
| Bundles | 2 | 0 | 0 |
| Assessments | 2 | 1 | 0 |
| Subscription plans | 2 | 0 | 0 |
| Certificates | 1 | 1 | 1 |
| User subscriptions | 1 | 0 | 0 |
| User roles | 1 | 0 | 0 |
| Installments | 1 | 0 | 0 |
| Leads | 1 | 0 | 0 |
| Calendar | 1 | 0 | 0 |
| Event logs | 1 | 0 | 0 |
| Asynchronous actions | 1 | 0 | 0 |
| Update user progress | 0 | 2 | 0 |
| **Total** | **59** | **27** | **8** |
</details>

## Run from source (stdio, no Docker)

Prefer the classic stdio mode for Claude Desktop? Build it locally:

```bash
npm install
npm run build
```

Then point Claude Desktop at the compiled entrypoint in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "learnworlds": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/Learnworlds-MCP/dist/index.js"],
      "env": {
        "MCP_TRANSPORT": "stdio",
        "LEARNWORLDS_BASE_URL": "https://your-school.learnworlds.com/admin/api",
        "LEARNWORLDS_API_TOKEN": "your-access-token",
        "LEARNWORLDS_CLIENT_ID": "your-client-id"
      }
    }
  }
}
```

## Keeping the spec current

The bundled `spec/learnworlds-openapi.yaml` is the source of truth for the tools. To
refresh against a newer API version, drop the new YAML in its place (or point
`LEARNWORLDS_OPENAPI_PATH` at it) and rebuild:

```bash
docker compose up -d --build   # Docker
# or
npm run build                  # from source
```

New paths become new tools automatically β€” no code changes needed.

## Development

```bash
npm install
npm run build      # compile TypeScript β†’ dist/
npm test           # run the Vitest suite
npm run list-tools # print the categorized tool catalog (no credentials needed)
```

CI builds and tests every push across Node 20 and 22; pushes to `main` also publish a
Docker image to the GitHub Container Registry. When the version in `server.json` is one
the official MCP Registry does not yet list, that same run also publishes it there.

## Notes & conventions

- **Transports**: `MCP_TRANSPORT=stdio` (default) for local launchers; `MCP_TRANSPORT=http`
  for the always-on Streamable-HTTP server the Docker image runs.
- **Paging**: most `get` tools accept `page` (and endpoint-specific filters); LearnWorlds
  paginates list responses (commonly 20–50 items per page).
- **Rate limit**: LearnWorlds allows 30 requests / 10 s; the server self-throttles at
  `LEARNWORLDS_MAX_REQUESTS` per `LEARNWORLDS_RATE_WINDOW_MS` (default 25 / 10 s) and
  retries any `429` it still receives.
- **Request bodies**: write tools take a `body` argument; its schema is resolved from the
  spec and shown to the model.

## Security

- Your API credentials live only in `.env`, which is git-ignored. **Never commit real
  secrets.** If the token leaks, rotate it in
  **LearnWorlds admin β†’ Settings β†’ Integrations β†’ Developers (API)**.
- **The HTTP endpoint requires a token as soon as it is bound beyond loopback.**
  Without `MCP_AUTH_TOKEN` the server refuses to start and the error explains what
  to do. Send it as an `Authorization: Bearer <token>` header, ideally behind TLS.
- **On localhost too:** without a token the `Host` header is restricted to
  localhost names, so no web page can reach the endpoint via DNS rebinding. A
  loopback bind alone is **not** protection. Behind a reverse proxy, set
  `MCP_ALLOWED_HOSTS`.
- **Behind a reverse proxy**, pin the `Host` header in the proxy to the internal
  upstream name and put exactly that into `MCP_ALLOWED_HOSTS`. The check then does
  not depend on the public domain and survives a domain change.
  (Tip from [@WinFuture23](https://github.com/WinFuture23).)
- **`/health` sits behind the Host check** but in front of the token check, so a
  platform health check needs no token. It additionally accepts `localhost`,
  `127.0.0.1` and `[::1]` at all times, so the `HEALTHCHECK` in the bundled
  Dockerfile keeps working when you pin `MCP_ALLOWED_HOSTS` to a public
  hostname. If your platform probes over HTTP from elsewhere, its hostname has
  to be in the list: Railway sends `Host: healthcheck.railway.app`.

See [SECURITY.md](./SECURITY.md) for the full policy and how to report a vulnerability.

## Credits & license

An unofficial community integration for [LearnWorlds](https://www.learnworlds.com/);
not affiliated with or endorsed by LearnWorlds. Built on the
[Model Context Protocol](https://modelcontextprotocol.io). Licensed under the
[MIT License](./LICENSE.md).

TDQS

A3.8/5.0

Scored across 94 tools

Disambiguation3/5

Most tools target distinct resources, but several inverse pairs have confusingly similar names (get_user_segments vs get_users_segment, get_products_user vs get_users_product) and membership queries vary in phrasing (get_user_groups_that_user_is_member vs get_users_user_group). Descriptions help, but with 94 tools, misselection risk is real.

Naming Consistency2/5

The verb_noun snake_case pattern dominates, but deviations are frequent: deletes_user_group (vs delete_*), get_list_subscription_plans, get_specific_seat_offering, update_certificate_reissue, review_submission_user_s_assessment_submission, and mark_as_complete. Inverse relations use inconsistent orderings like get_courses_enrollments_user vs get_users_per_course.

Tool Count2/5

94 tools is far beyond a well-scoped MCP surface and will overwhelm agent tool selection. While the LearnWorlds domain is broad, this volume includes many niche read-only endpoints that could be consolidated or omitted.

Completeness3/5

Coverage is uneven: courses, users, groups, seats, and community spaces have CRUD-like surfaces, while bundles, subscription plans, payments, leads, event logs, posts, and collections are read-only. Obvious lifecycle operations like delete user, delete course, or create certificate are absent.

Maintenance

ActivityMaintained
ResponsivenessNo issues