Initial schulcloud-mcp server
Read-only MCP server exposing a Schulcloud account to Claude: courses,
column boards, lessons, tasks, and file downloads with text extraction.
The API surface was verified against the live instance rather than
inferred from upstream source, which changed several design decisions:
- The `jwt` cookie works verbatim as `Authorization: Bearer` and lasts 30
days, so there is no cookie jar and no refresh-session timer.
- Course contents live at /api/v3/course-rooms/{courseId}/board; there is
no GET /api/v3/courses/{id}.
- Files are a separate service (/api/v3/file/*) with its own OpenAPI doc.
- Board file elements carry no file id; attachments are resolved by
listing files-storage with parentType=boardnodes and the element id.
Read-only by construction: every client method is a GET, including the
api_get escape hatch. The endpoint is internet-facing by necessity, so a
leaked token being unable to act as the user is the key safety property.
Deploys as a container behind the Pi's existing Caddy, guarded by a
constant-time bearer check. Stateless — no database.
Verified: 28 unit tests, plus a 30-check end-to-end run driving a real
MCP client over Streamable HTTP against the live account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
135
docs/API.md
Normal file
135
docs/API.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# The Schulcloud API, as verified against this instance
|
||||
|
||||
Everything here was confirmed against `https://schulcloud-thueringen.de` with a
|
||||
real student account on 2026-09-11, not inferred from source. Where upstream
|
||||
source and live behaviour disagreed, live behaviour won.
|
||||
|
||||
## Two services, one origin
|
||||
|
||||
| Service | Source repo | Base path | Self-documenting at |
|
||||
|---|---|---|---|
|
||||
| Main server (NestJS) | [`schulcloud-server`](https://github.com/hpi-schul-cloud/schulcloud-server) | `/api/v3/` | `/api/v3/docs`, `/api/v3/docs-json` |
|
||||
| Files storage | [`file-storage`](https://github.com/hpi-schul-cloud/file-storage) | `/api/v3/file/` | `/api/v3/file/docs`, `/api/v3/file/docs-json` |
|
||||
|
||||
Both accept the same bearer token. The files service was split out of
|
||||
`schulcloud-server` into its own repository, which is why no `file` paths
|
||||
appear in the main `docs-json` — a detail that will send you in circles if you
|
||||
only read the main spec. Fetch both:
|
||||
|
||||
```bash
|
||||
curl -s "$TSC_URL/api/v3/docs-json" -o docs-v3.json # 212 paths
|
||||
curl -s "$TSC_URL/api/v3/file/docs-json" -o docs-file.json # 26 paths
|
||||
```
|
||||
|
||||
These are the authoritative reference for *this* instance's deployed version.
|
||||
Prefer them over the GitHub sources, which track `main` and may be ahead.
|
||||
|
||||
## Which repositories matter
|
||||
|
||||
The `hpi-schul-cloud` org has ~100 repos, most archived or superseded. The live
|
||||
ones relevant here:
|
||||
|
||||
- **`schulcloud-server`** — the API. Read `apps/server/src/modules/<module>/api/`
|
||||
for controllers and DTOs.
|
||||
- **`file-storage`** — the files service, extracted from the above.
|
||||
`src/modules/files-storage/api/controller/files-storage.controller.ts` is the
|
||||
whole surface.
|
||||
- **`nuxt-client`** — the current web front end. Useful for seeing which API
|
||||
calls the real UI makes in which order.
|
||||
- **`schulcloud-client`** — the *legacy* Handlebars front end. Still receives
|
||||
commits, but it is not where new features land.
|
||||
|
||||
Superseded/archived and worth ignoring: `authorization-service`,
|
||||
`schulcloud-editor`, `nexboard-api-js`, `end-to-end-tests`, `docker-compose`,
|
||||
`H5P-Nodejs-library`, `shd-client`.
|
||||
|
||||
Note the naming: `/api/v1` is the old Feathers surface. On this instance
|
||||
`/api/v1/docs` 404s, and the v3 NestJS API covers everything this server needs.
|
||||
|
||||
## Content model
|
||||
|
||||
```
|
||||
Course ─┬─ column board ─── column ─── card ─── element ─┬─ richText
|
||||
│ ├─ file ──── fileRecord(s)
|
||||
│ ├─ link
|
||||
│ └─ …
|
||||
├─ lesson (Thema) ─── contents[] + materials[]
|
||||
└─ task (Aufgabe) ─── description + fileRecord(s)
|
||||
```
|
||||
|
||||
On the account this was built against: 26 courses holding 30 column boards, 18
|
||||
lessons, 42 tasks and 175 files. **Column boards hold the great majority of
|
||||
current material**; lessons are the older format.
|
||||
|
||||
## Endpoints this server uses
|
||||
|
||||
| Purpose | Call |
|
||||
|---|---|
|
||||
| Identity, school id, permissions | `GET /api/v3/me` |
|
||||
| Courses | `GET /api/v3/courses?skip&limit` |
|
||||
| One course's contents | `GET /api/v3/course-rooms/{courseId}/board` |
|
||||
| Dashboard tiles | `GET /api/v3/dashboard` |
|
||||
| Tasks | `GET /api/v3/tasks`, `GET /api/v3/tasks/finished` |
|
||||
| Lesson body | `GET /api/v3/lessons/{lessonId}` |
|
||||
| Lesson's tasks | `GET /api/v3/lessons/{lessonId}/tasks` |
|
||||
| Board structure | `GET /api/v3/boards/{boardId}` |
|
||||
| What a board belongs to | `GET /api/v3/boards/{boardId}/context` |
|
||||
| Card bodies | `GET /api/v3/cards?ids=<id>&ids=<id>` |
|
||||
| Files of an entity | `GET /api/v3/file/list/{storageLocation}/{storageLocationId}/{parentType}/{parentId}` |
|
||||
| One file's metadata | `GET /api/v3/file/{fileRecordId}` |
|
||||
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
|
||||
| News | `GET /api/v3/news` |
|
||||
|
||||
### Gotchas that cost real time
|
||||
|
||||
**`course-rooms`, not `courses`, for course contents.** `GET /api/v3/courses/{id}`
|
||||
does not exist. The route that returns a course's lessons/tasks/boards is
|
||||
`GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id.
|
||||
Nothing in the naming suggests this.
|
||||
|
||||
**`/api/v3/rooms` is a different feature.** "Rooms" are the newer standalone
|
||||
collaboration spaces, unrelated to courses. On this instance the account has
|
||||
none, so `GET /api/v3/rooms` returns `{"data":[]}` — which reads like a broken
|
||||
endpoint but is simply an empty feature.
|
||||
|
||||
**`limit` maxima are enforced and mis-documented.** The OpenAPI schema says
|
||||
`maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to
|
||||
satisfy both. Asking for 200 returns a `400 API_VALIDATION_ERROR`, not a
|
||||
truncated list.
|
||||
|
||||
**There is no `GET /tasks/{id}`.** Single-task detail has to be assembled: the
|
||||
list endpoints give metadata but *omit `description`*, which appears only on the
|
||||
course page's task element. `get_task` does this join.
|
||||
|
||||
**Board files need three calls.** A `file` element's `content` carries only
|
||||
`{caption, alternativeText}` — no file id. The bytes are found by listing
|
||||
files-storage with `parentType: 'boardnodes'` and the **element** id as
|
||||
`parentId`. This is the single least discoverable part of the API, and applies
|
||||
equally to `fileFolder` and `drawing` elements.
|
||||
|
||||
**`storageLocationId` is the school id** (from `/me`), with
|
||||
`storageLocation: 'school'`, for every parent type in normal use.
|
||||
|
||||
**Lesson ids come back as buffers.** `GET /api/v3/lessons/{id}` returns nested
|
||||
ids as `{buffer:{type:'Buffer',data:[...]}}` rather than hex strings — a leak
|
||||
from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts`
|
||||
converts them.
|
||||
|
||||
**`Content-Disposition` on downloads is malformed.** It comes back as
|
||||
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
|
||||
percent-encoded inside the quotes. Parse defensively.
|
||||
|
||||
### Content element types
|
||||
|
||||
From `ContentElementType` in `schulcloud-server`, all seen live except where
|
||||
noted: `richText`, `file`, `fileFolder`, `link`, `drawing`,
|
||||
`collaborativeTextEditor`, `externalTool`, `videoConference`, `h5p`, `deleted`.
|
||||
|
||||
Collaborative text editor contents are **not** retrievable through the API —
|
||||
`GET /api/v3/collaborative-text-editor/{parentType}/{parentId}` returns a URL to
|
||||
the Etherpad-style editor, not the document text.
|
||||
|
||||
## Re-verifying after an upstream release
|
||||
|
||||
`npm run probe` re-checks every assumption above against the live instance and
|
||||
prints what it finds, including days left on the token.
|
||||
Reference in New Issue
Block a user