Files
Schulcloud-MCP/docs/API.md
MechaCat02 ac08e17b48 Expose submissions: list_submissions, and get_task shows your own
Adds what the API actually permits, which is less than the request asked
for and worth being precise about.

GET /api/v3/submissions/status/task/{taskId} is the only submission
route — no list, no fetch-by-id — so a task id is the only way in. The
probe in the report missed it by trying /api/v3/submissions (404). Its
payload is {id, submitters, isSubmitted, isGraded, grade,
submittingCourseGroupName} and nothing more: no submitted text, no grade
comment, no graded-at. Those lived on /api/v1, which this instance does
not serve at all (404 across the board, confirmed — not the proxy). So
"what feedback did I get" is answerable only when the feedback is a file.

Submitted files are reachable, which covers the main workflow:
get_task now shows the submission id, graded state, grade, group, and
the handed-in files with ids ready for download_file. list_submissions
surveys tasks for "what have I handed in" and "what is still ungraded".
Both state the text/feedback gap rather than implying none was given.

Two things found while building it:

files-storage ignores the parentType path segment when listing —
.../gradings/{id} returns the same records, saying parentType
"submissions". Filtering on each record's own parentType, or a student's
own upload gets reported back as teacher feedback.

get_task could not find this task at all: the task lists only cover the
dashboard, and group-project tasks are absent from both, so it claimed
the id was wrong for a task the account can plainly see. It now falls
back to scanning course pages.

Also bounds live search by measured cost: resolving attachments needs a
request per board element, which is 2s for one course but 325s for all
of them — beyond any client timeout. An unscoped fresh search now reads
text only and says so.

38/38 smoke checks; verified end to end through Claude Code against a
real graded group submission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:19:11 +02:00

8.8 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.

Submissions are nearly invisible. The only route is GET /api/v3/submissions/status/task/{taskId} — there is no GET /submissions and no fetch-by-id, so a task id is the only way to reach a submission. The response carries {id, submitters, isSubmitted, isGraded, grade, submittingCourseGroupName} and nothing else: no submitted text, no grade comment, no graded-at. Those lived on the legacy Feathers API, and /api/v1 is not served on this instance (404 across the board), so they are simply unavailable. Submitted files are reachable through files-storage with parentType: 'submissions'.

files-storage ignores parentType when listing. Asking for .../gradings/{submissionId} returns the files parented to that id whatever their type — the records come back saying parentType: "submissions". The path segment appears to serve authorisation, not filtering, so filter on each record's own parentType or a student's own upload will be reported back as teacher feedback.

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.