Files
Schulcloud-MCP/local-instance/README.md
MechaCat02 1de026ca43 Serve rooms ("Räume"), which are not courses however the urls read
The account this was built against is in no rooms, so the whole space was
invisible and easy to dismiss as an empty endpoint. It is not empty in
general — the user had rooms until a teacher removed access — and the
UI's naming actively hides the distinction: the sidebar's *Kurse* entry
links to `/rooms/courses-overview` and lists courses, while *Räume* links
to `/rooms` and lists rooms. A url containing `/rooms` identifies neither.

list_rooms and get_room cover the latter. A room holds boards and nothing
else, so get_room lists boards for get_board (which already reports "in
room" from the board context) plus who else is in it. Room boards report
`isVisible`, which the course-page projection does not, so a draft is
named as a draft instead of being offered and then answering 403.

Rooms also go through the crawl, or they would have become the next
blind spot: their boards are indexed, searchable by both the index and
the live-crawl path, diffed by what_changed, and mirrored by the CLI
under the room's name. The board traversal and the snapshot matcher are
now shared between courses and rooms rather than duplicated, which also
fixed the live-crawl path silently not searching pad contents.

The CLI needed no new command — it is file-centric and inherits rooms
through the manifest — but `--course` now accepts a room id, and says so.

`kind` gains 'room'; the column is plain TEXT, so no migration. 112 tests.
Smoke: 42/42 and 44/44 local, 41/41 and 43/43 live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 19:24:53 +02:00

258 lines
11 KiB
Markdown

# 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
```bash
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 use `schulcloud`, the built-in `admin`/`lehrer` and the
> teacher `klara.fall` use `Schulcloud1!`, and the `*.qa` accounts use
> `Schulcloud1qa!`.
To add the remaining external tools (H5P, tldraw, Collabora):
```bash
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:
```console
$ 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:
```bash
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_ENABLED` is 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 `av` profile.
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`:
```bash
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_pass` goes through a **variable plus a `resolver`**, 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.
```bash
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:
```bash
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 no `create`, and v3 has no team
route beyond news and create-room; `POST /teams` answers 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 `create` publishes 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.
**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.