Skip to main content
Glama
README.md
# stepik-mcp

[![CI](https://github.com/prog420/stepik-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/prog420/stepik-mcp/actions/workflows/ci.yml)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

An [MCP](https://modelcontextprotocol.io) server that lets an AI assistant author courses on
[Stepik](https://stepik.org): browse the catalogue, read a course's structure, and create or
edit courses, modules, lessons and steps through the [Stepik REST API](https://stepik.org/api/docs).

**It cannot publish.** Courses it creates stay private drafts and no tool can make one public —
that stays a human decision in the Stepik web interface. See [Safety](#safety) below.

## Requirements

- Python 3.10+
- Stepik OAuth application credentials (free)

## Getting credentials

1. Go to <https://stepik.org/oauth2/applications/> and register a new application.
2. Set **Client type** to `Confidential` and **Authorization grant type** to `Client credentials`.
3. Copy the **Client id** and **Client secret**.

The token issued by this grant belongs to the account that owns the application, so everything
this server creates is owned by that account. Run `stepik_whoami` to confirm which one it is.

## Install

With [uv](https://docs.astral.sh/uv/) (no manual install needed — `uvx` fetches on demand):

```bash
uvx --from git+https://github.com/prog420/stepik-mcp stepik-mcp
```

Or from a clone:

```bash
git clone https://github.com/prog420/stepik-mcp
cd stepik-mcp
uv sync --extra dev
uv run stepik-mcp
```

## Configuration

All configuration is via environment variables.

| Variable | Default | Meaning |
| --- | --- | --- |
| `STEPIK_CLIENT_ID` | — | **Required.** OAuth application client id. |
| `STEPIK_CLIENT_SECRET` | — | **Required.** OAuth application client secret. |
| `STEPIK_ALLOW_PUBLISH` | `0` | Set to `1` to allow visibility changes. See [Safety](#safety). |
| `STEPIK_ALLOW_DELETE` | `1` | Set to `0` to make the server read-and-create only. |
| `STEPIK_BASE_URL` | `https://stepik.org` | Override for a self-hosted instance. |
| `STEPIK_TIMEOUT` | `30` | HTTP timeout in seconds. |
| `STEPIK_MAX_PAGES` | `5` | Page cap when following paginated listings. |

A `.env` file in the working directory is loaded automatically; copy `.env.example` to start.

### Claude Code

```bash
claude mcp add stepik \
  --env STEPIK_CLIENT_ID=your-id \
  --env STEPIK_CLIENT_SECRET=your-secret \
  -- uvx --from git+https://github.com/prog420/stepik-mcp stepik-mcp
```

### Claude Desktop / Cursor

In `claude_desktop_config.json` or `.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "stepik": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/prog420/stepik-mcp", "stepik-mcp"],
      "env": {
        "STEPIK_CLIENT_ID": "your-id",
        "STEPIK_CLIENT_SECRET": "your-secret"
      }
    }
  }
}
```

### Remote / containerised

```bash
stepik-mcp --transport http --host 0.0.0.0 --port 8000
# -> streamable HTTP at http://<host>:8000/mcp
```

The HTTP transport has no authentication of its own. Do not expose it to a network you do not
control — anyone who can reach it can act as your Stepik account.

## Content model

Stepik's hierarchy is worth knowing, because one part of it is not obvious:

```
course
└── section        (a module)
    └── unit       (a placement, not content)
        └── lesson (exists independently of any course!)
            └── step  (theory or a quiz)
```

A **lesson is a standalone object**, not a child of a course. Placing one into a course means
creating a **unit** that points at a section and a lesson. `stepik_add_lesson_to_course` does the
lesson + unit + steps sequence in a single call, which is the normal way to add content.

Steps are edited through **step-sources** (the authoring object), not `steps` (the read-only
learner view).

## Tools

### Reading

| Tool | Purpose |
| --- | --- |
| `stepik_whoami` | Which Stepik account the server acts as. |
| `stepik_health_check` | Credentials, API reachability, and which safety switches are on. |
| `stepik_search_courses` | Free-text search of the public catalogue. |
| `stepik_list_courses` | List courses; with no arguments, the ones you own. |
| `stepik_get_course` | One course's metadata. |
| `stepik_get_course_structure` | **The whole tree** — sections, units, lessons, steps, with every id. |
| `stepik_get_lesson` | One lesson and its step ids. |
| `stepik_list_steps` | Steps of a lesson with short previews. |
| `stepik_get_step_source` | A step's full editable content, including quiz options and answers. |

### Authoring

| Tool | Purpose |
| --- | --- |
| `stepik_create_course` | Create a course. Always a private draft. |
| `stepik_update_course` | Edit title, summary, description, workload, audience, requirements. |
| `stepik_create_section` / `stepik_update_section` | Manage modules. |
| `stepik_create_lesson` / `stepik_update_lesson` | Manage standalone lessons. |
| `stepik_create_unit` / `stepik_move_unit` | Place and reorder lessons within a course. |
| `stepik_create_step` / `stepik_update_step` | Manage step content of any type. |
| `stepik_add_lesson_to_course` | **Composite:** lesson + unit + steps in one call. |
| `stepik_delete_section` / `_lesson` / `_unit` / `_step` | Deletion; each requires `confirm=true`. |

Positions are optional everywhere — omit `position` and the new object is appended to the end.

### Step types

`stepik_create_step` covers every type through one `block_type` argument:

| `block_type` | Extra arguments |
| --- | --- |
| `text` | none — a theory/reading step |
| `choice` | `options` (≥2, ≥1 with `is_correct`); single vs multiple answer is inferred |
| `sorting` | `options` in the correct order |
| `matching` | `pairs` (≥2) of `first`/`second` |
| `string` | `answer`; optional case sensitivity, substring and regex matching |
| `number` | `answer` (numeric), optional `max_error` |
| `math` | `answer` as an expression |
| `free-answer` | none — reviewed manually |
| `code` | `code_tests` (input/output), optional `code_template`, `code_language` |

Invalid combinations are rejected before the request is sent, with a message that says what is
missing rather than a bare HTTP 400.

### Resources

- `stepik://me` — the authenticated account
- `stepik://course/{course_id}` — a markdown outline of a course

## Safety

The point of this server is that an assistant can build a course without being able to ship it.

**Publishing is blocked in the client, not in the tools.** Removing a `publish` tool alone would
be theatre: an agent could still call `update_course(is_enabled=true)`. Instead, every write
passes through one chokepoint in [`client.py`](src/stepik_mcp/client.py) that strips or rejects
fields which would widen visibility (`is_enabled`, `is_public`, `is_featured`). Consequences:

- Created courses are forced to `is_enabled: false`.
- Created lessons are forced to `is_public: false`.
- An update that would publish raises an error and **no request reaches Stepik**.
- Editing a course that is *already* published still works — only *widening* visibility is blocked,
  and unpublishing is always allowed.
- `STEPIK_ALLOW_PUBLISH=1` lifts the block. It is an operator decision made when starting the
  server, not something a tool call can reach.

This is covered by [`tests/test_publish_guard.py`](tests/test_publish_guard.py); if those tests
fail, the guarantee is gone.

**Deletion** is gated twice: `STEPIK_ALLOW_DELETE` must be on (it is by default) *and* each delete
call must pass `confirm=true`. Delete tools are marked with the MCP `destructiveHint` annotation
so clients can prompt. Deleting a lesson or step also destroys learner submissions, and Stepik has
no undo.

## Development

```bash
uv sync --extra dev
uv run pytest          # 86 tests, no network access
uv run ruff check .
uv run ruff format .
```

Tests mock the HTTP layer with [respx](https://lundberg.github.io/respx/), so the suite never
touches stepik.org and needs no credentials.

To inspect the server interactively:

```bash
npx @modelcontextprotocol/inspector uv run stepik-mcp
```

## Notes on the Stepik API

Things that surprised us, recorded so the next person does not have to rediscover them:

- Every GET returns a **list**, even for a single id: `/api/courses/1` yields `{"courses": [...]}`.
  A missing object comes back as an empty list with HTTP 200, not a 404.
- The schema is **flat** — there are no nested endpoints like `/courses/1/sections`. Use
  `/sections?course=1` or the `?ids[]=` batch form, which every endpoint supports.
- `/api/stepics/1` ("who am I") is a **trap**. The `1` means "me", but the `stepics` object it
  returns also has `id: 1`, so trusting that id resolves to user #1 — the site's very first
  account — for everyone. The real profile is the sibling `users` array of the same response.
- Request bodies are wrapped in a singular envelope: `{"course": {...}}`. Step sources are the
  exception and use camelCase `{"stepSource": {...}}`; this client sends that and retries with
  `step-source` if Stepik rejects it.
- `PUT` replaces the whole object, so all updates here are read-modify-write. Sending only the
  changed field would blank out everything else.

## License

MIT — see [LICENSE](LICENSE).