Files
Schulcloud-MCP/docs/API.md
MechaCat02 634b004985 Fix get_board on boards with more than 20 cards
GET /api/v3/cards?ids= accepts at most 20 ids. Above that the request
fails with 400 "each value in ids must be a mongodb id" — which blames
the ids when the real problem is how many there are. Express/NestJS
parse the query string with qs, whose default arrayLimit is 20; past it
the repeated params stop being an array and become an object keyed "0",
"1", …, and @IsMongoId({ each: true }) then rejects every value.

I had chunked at 40, having read the controller and its DTO and found no
documented ceiling. The limit is not there — it is in the query parser
underneath them, which I did not think to check. Verified live: 20 ids
return 200, 21 return 400 with identical ids.

The worse half of this was mine alone. The crawler caught assembleBoard
failures and dropped them, so every board over 20 cards vanished from
the index while the crawl reported "failures: none". Board errors now go
into Snapshot.failures and are surfaced by refresh_index.

Impact of both fixes on a full re-crawl: 205 files -> 255, and the
reported board (27 cards, 18 files) reads fully. The two failures that
remain are genuine 403s — boards this account cannot see — and are now
visible rather than silent.

Thanks to the bug report, which had the root cause exactly right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:37:47 +02:00

7.9 KiB

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 /api/v3/ /api/v3/docs, /api/v3/docs-json
Files storage 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:

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
Instance settings (no auth) GET /api/v3/config/public
Remaining idle budget POST /api/v3/authentication/refresh-session{expiresInSeconds}

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.

GET /cards?ids= accepts at most 20 ids. Above that the request fails with 400 "each value in ids must be a mongodb id" — blaming the ids when the real problem is how many there are. Express/NestJS parse the query string with qs, whose default arrayLimit is 20; past it, repeated params become an object keyed "0", "1", … and @IsMongoId({ each: true }) then rejects every value. Nothing in the controller or its DTO says so: the limit lives in the query parser underneath them. Verified live — 20 ids return 200, 21 return 400 with identical ids. A board with more than 20 cards is therefore unreadable in one request; getCards chunks at 20.

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.

The JWT's exp is not the session lifetime, and the source misleads here. Both the current and legacy whitelist implementations re-set a Valkey TTL on every authenticated request, which reads as a sliding window. The live instance does not behave that way: a session ends ~2 h after login, and successful reads in between do not extend it (measured — see docs/AUTH.md). This is the clearest case in this API of live behaviour diverging from upstream source.

GET /api/v3/config/public is unauthenticated and useful. 78 keys of instance configuration, including the session timeouts and feature flags. Handy for checking deployed settings without a token.

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 — both token clocks included: days until hard expiry, and seconds of idle budget remaining.