Every area — courses, rooms, boards, topics, tasks, files, quizzes, teams,
groups, submissions, grades — was checked for data the instance has and the
tools did not show.
Grades and feedback. A teacher's /homework page is a different page from a
student's: grade and comment live in the grading form, one block per
submission, so a teacher account reported every graded submission as having
neither. parseTeacherGrading reads the form, and list_submissions can now
include the written feedback and who handed the work in.
Names. /api/v1 is partly served: courses, users and classes survive in the
deployment's ingress table, and users/{id} is the only route from an id to a
name. Submitters, file creators and course teachers resolve through it, and
degrade to "not visible to this account" where a student may not read them.
Courses, rooms and classes. get_course adds the description, teachers,
member count and weekly timetable from /api/v1/courses. list_classes is new.
get_room reports what the account may do — allowedOperations is an object of
booleans, not the list it was typed as — and applicants and invitation links
where it may manage them.
Board and topic content. Link descriptions, image alt text, drawing and
video-conference titles, the ids behind external tools and H5P content (the
only thing resembling a quiz), and what a deleted element used to be. Topic
Etherpad pads are read like board pads, and htmlToText keeps table columns
apart and drops template indentation.
Files. A scan with no text layer falls back to the preview endpoint, whose
width and outputFormat are undocumented enums, so Claude gets a picture of
the page; list_files reports counts and sizes. Teams stay documented as
unreadable at any API version; their files come later.
What the crawl missed. Tasks attached to topics (18 of 60 on the live
account), each course's own file area, and — behind INDEX_PERSONAL_FILES —
personal files and submissions with their grade comments, so search and
what_changed cover grading. A submission hit points at get_task.
The local instance's preview profile gets an ImageMagick policy that allows
the coders its 7.1.2 build needs; the image's own denies them all.
110 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A local Schulcloud
A Docker Compose stack that runs a real Schulcloud instance on this machine, modelled on schulcloud-thueringen.de — the instance this MCP server reads.
It exists so that the parts of the product we can only observe from a student's
account can also be produced: log in as the teacher, grade a submission, then
look at it through list_submissions and see whether what we render matches
what the student is shown.
Everything here is a throwaway development instance. The credentials are the upstream development defaults, published in the upstream repositories. Bind nothing beyond
127.0.0.1.
Quick start
cd local-instance
docker compose up -d # ~5 min on a cold cache
./scripts/seed.sh # loads the demo school; takes another minute or two
Then open http://localhost:4400 and sign in as
demo-schueler@schul-cloud.org / schulcloud (Fritz Schmidt, a student with
graded submissions). seed.sh prints the full account list on completion.
The demo password differs by account — an upstream quirk, not ours. The two
demo-*accounts useschulcloud, the built-inadmin/lehrerand the teacherklara.falluseSchulcloud1!, and the*.qaaccounts useSchulcloud1qa!.
To add the remaining external tools (H5P, tldraw, Collabora):
docker compose --profile tools up -d
Why the images are the interesting part
quay.io/schulcloudverbund/* are the same images the live instance runs,
built for the thr theme, and they are public. So this is not a rebuild of
main that may have drifted — it is the deployed artefact, pinned to the
version the live instance reports:
$ curl -s https://schulcloud-thueringen.de/version
{"client": {"version": "33.40.0"}, "nuxt-client": {"version": "33.40.1"},
"server": {"version": "33.40.2"}, "dof_app_deploy": "33.40.2"}
Hence SC_VERSION defaults to 33.40. To pin something else:
SC_VERSION=33.27 docker compose up -d
CLAUDE.md says live behaviour beats upstream source — the clones in
vendor/ track main and may be ahead of what is deployed. Running the
deployed images is how that rule is honoured here rather than worked around.
What is faithful, and what is not
Faithful, and load-bearing for what we test:
| Images | the live instance's own, thr theme, tag 33.40 |
| Feature flags | env/api.env is a replay of GET /api/v3/config/public from the live instance, not a hand-picked set |
| Instance identity | env/shared.env mirrors dof_app_deploy/ansible/group_vars/thr/instance_cfg.yml |
| URL routing | proxy/nginx.conf is generated from the deployment's own ingress table (see below) |
| Session model | Valkey in single mode, so the JWT whitelist expires sessions exactly as production does |
| Seed data | the upstream demo school, including graded and ungraded submissions |
Deliberately different:
- No external OAuth / Schulportal login. Excluded by request, and it is the one part that cannot be stood up locally. Local login is username + password against the seeded accounts.
- No BigBlueButton.
FEATURE_VIDEOCONFERENCE_ENABLEDis off; upstream it is on. BBB is a separate product of its own scale. FEATURE_CONSENT_NECESSARY=false, or every seeded user hits a consent wall before reaching any content.- No LDAP / TSP sync, no Nextcloud, no calendar service.
- Antivirus off by default — see the
avprofile.
Every one of these is marked in the env files at the line it affects.
Layout
docker-compose.yml the stack; profiles: (default) | tools | av | preview
env/ one file per service, all values local-only
proxy/nginx.conf GENERATED — the single origin, see scripts/gen-proxy-conf.py
scripts/seed.sh loads the demo school via the management app
scripts/minio-init.sh creates the S3 buckets each service expects
etherpad/APIKEY.txt fixed Etherpad API key, matching env/api.env
Ports
| Port | |
|---|---|
| 4400 | the instance — everything a browser touches |
| 3030 | server API, direct |
| 3333 | management app (seeding only, not part of a running instance) |
| 3100 / 4000 | legacy client / SPA, direct |
| 4444 | file-storage |
| 9900 / 9901 | MinIO S3 API / console (miniouser / miniouser) |
| 9980 | Collabora (tools profile) |
| 27019 | MongoDB |
| 6381 | Valkey |
| 15673 | RabbitMQ management |
Everything binds to 127.0.0.1. Port numbers are shifted off their upstream
defaults where those commonly collide (Mongo, Valkey, RabbitMQ, MinIO) — note
docker-compose.override.yml in the repo root already uses 8080 and 55432.
The proxy is generated, not written
The live instance is one origin whose paths are split across the legacy client,
the SPA, and several APIs. That split is not cosmetic: /rooms/courses-list is
the SPA, /courses/:id is the legacy client, and /api/v3/file/ is a
different service from /api/v3/. Get it wrong and you are testing a different
application from the one students use.
The rules live in dof_app_deploy/ansible/group_vars/all/x_ingress.yml (46 of
them) plus a per-path ingress inside each service repo. Transcribing that by
hand invites drift, so scripts/gen-proxy-conf.py reads the deployment repo and
emits proxy/nginx.conf:
python3 scripts/gen-proxy-conf.py > proxy/nginx.conf
docker compose restart proxy
It needs the upstream clones in ../vendor (gitignored — see the root README),
which is why the output is committed.
Two details in there are worth keeping:
- Every
proxy_passgoes through a variable plus aresolver, so names are resolved per request. With a literal upstream, nginx refuses to start whenever an optional profile is down — which is the normal case. - Etherpad gets the deployment's own rewrite rules, not a plain
proxy_pass. It is mounted under a prefix it knows nothing about; without the rewrites a pad loads and then silently never syncs.
Seeding
scripts/seed.sh does what the real deployment's init job does: it asks the
management app to load backup/setup/*.json, which ships inside the server
image. It also registers MinIO in the storageproviders collection, which has
no seed data on purpose (it holds credentials) and without which legacy file
uploads fail.
Re-running it is safe; collections are replaced, not appended to.
What the demo data already contains
The seed includes 50 tasks and 24 submissions covering the grading states that are otherwise hard to obtain — including the two this server renders:
graded: true, grade: null, gradeComment set |
graded by feedback alone |
graded: true, grade: 100, gradeComment set |
a percentage and feedback |
graded: false, submitted: true |
handed in, not yet looked at |
The second row matters: no submission in the real account has ever had a
numeric grade, so formatGradeState's percentage branch had never been seen
against real data. Here it can be.
Simulating a teacher
scripts/simulate-teacher.mjs creates, edits and deletes the things teachers
create, so the MCP server can be run against content the real account has never
held. It is the only thing in this repository that writes to a Schulcloud, and
it refuses to run against anything but a localhost address.
node scripts/simulate-teacher.mjs create # course, room, topic, task, board,
# columns, cards, rich text, link,
# Etherpad pad, folder, files
node scripts/simulate-teacher.mjs update # rename and rewrite all of it
node scripts/simulate-teacher.mjs delete # remove it again
Ids are kept in .simulate-teacher.json between phases, so the MCP server can
be pointed at the instance in between:
eval "$(./scripts/mcp-env.sh)" # as the demo student
cd .. && npm run smoke # 39 checks against the local instance
It also builds a room ("Raum"): rooms are a separate space from courses,
and the naming misleads — the sidebar's Kurse entry points at
/rooms/courses-overview while Räume points at /rooms. The fixture adds the
demo student to one room and leaves a second room without them, so "only the
rooms I belong to" is testable rather than assumed.
Two things it does not do, because the API does not allow them:
- Teams cannot be created. The legacy service registers
['find','get','update','patch','remove']and nocreate, and v3 has no team route beyond news and create-room;POST /teamsanswers 405. The script adopts a seeded team and edits that instead. - A board is created unpublished. Students get 403 on it while the course
page still lists its title, so
createpublishes the main board and leaves a second one as a draft on purpose — both states are worth testing against.
seed.sh also drags the demo data into the present. The seed ships courses that
ended in 2018 and homework due in 2017, and the v3 endpoints filter on those
dates: a student sees no tasks at all in an ended course, which makes the whole
student-facing surface look empty for reasons that have nothing to do with the
code under test.
Profiles
| Profile | Services | Cost |
|---|---|---|
| (default) | Mongo, Valkey, RabbitMQ, MinIO, api, management, board-collaboration, admin-api, file-storage, client, nuxt, Etherpad, proxy | ~4.7 GB images |
tools |
H5P editor + static files + library install, tldraw server + worker, Collabora | ~3.3 GB more, Collabora is the bulk |
av |
ClamAV + Clammit | ~1.5 GB resident for the signature database |
preview |
file-preview generator (thumbnails) | small |
Etherpad is in the default profile even though it is an external tool. The
legacy client requests an Etherpad session on every topic page whose lesson
has contents — it never checks whether a pad is actually present. With Etherpad
down that call fails, validUntil comes back undefined, and Express rejects the
resulting session cookie with "option expires is invalid": a 500 on every
topic page, pad or no pad. The live deployment always runs it, so this is the
faithful configuration as well as the working one.
av is opt-in for a reason beyond size: with ENABLE_FILE_SECURITY_CHECK=true
but no scanner reachable, every upload stays at
securityCheck.status=pending and can never be downloaded. Turn the flag in
env/file-storage.env on together with the profile, or neither.
Troubleshooting
Everything 502s. The proxy is up before the apps are. docker compose logs -f api — the server waits for Mongo to become primary.
Mongo never becomes healthy. It runs as a single-node replica set because the migration runner opens transactions, which Mongo refuses on a standalone. The healthcheck initiates the set on first boot; give it ~30 s.
Login succeeds, then immediately bounces back. The jwt cookie is being
dropped. Check COOKIE__SECURE=false in env/client.env — the proxy speaks
plain HTTP locally.
A file uploads but will not download. See the av note above.
Previews never appear, and /api/v3/file/preview/... answers 404
PREVIEW_NOT_POSSIBLE. Two causes, both local. First, with no virus scanner
(see the av note) every upload stays securityCheck.status=pending, and a
record that has not been scanned reports previewStatus: awaiting_scan_status
— previews are gated on the scan. Second, the file-preview image ships an
ImageMagick policy written for an older ImageMagick than the 7.1.2 it actually
contains, so every coder it needs is denied and each attempt fails with
"attempt to perform an operation not authorized by the security policy" —
which the API surfaces as a 404. file-preview/policy.xml is mounted over the
image's own to fix the second; the first is inherent to running without av.
H5P element stays empty. docker compose --profile tools run --rm h5p-libraries and watch it finish; the editor has nothing to offer until the
content types are in the bucket.