Files
Schulcloud-MCP/local-instance/README.md
MechaCat02 ab581aa5ca Give claude.ai a token of its own, sent as a request header
claude.ai's connector dialog does offer request headers, on its second step,
after the URL has been probed, so the connector no longer needs the secret
path. MCP_AUTH_TOKEN already worked there as a bearer or X-Api-Key, but it
also opens /api, which can replace the Schulcloud token and stream the file
mirror, and claude.ai stores the header's value.

MCP_CONNECTOR_TOKEN is a second token, accepted on /mcp only and refused on
/api, and rotated without touching Claude Code or the CLI. The config refuses
one shorter than 32 characters, equal to MCP_AUTH_TOKEN, or set without it,
and never echoes a value. Every accepted token is compared in full, so the
timing does not tell which one matched.

The gate also takes a bare Authorization value, because claude.ai sends a
header exactly as typed and its docs warn that most servers reject a token
entered without "Bearer ". It takes X-Auth-Token too, the other name its
dialog offers.

The docs now set up the header; the secret path stays as a fallback for
clients that cannot send one. 184 tests. Smoke 79/79 and 77/77 on the local
instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:03:56 +02:00

14 KiB

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 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):

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_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
                      (minio-loopback: see "Simulating a teacher")
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_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.

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
node scripts/simulate-teacher.mjs files    # only the file-manager part, onto an existing fixture

It also fills the file manager ("Dateien") — the legacy file system behind Persönliche, Kurs-, Team- and Geteilte Dateien, a separate store from the board files above — with a course folder tree ("Arbeitsblätter/Woche 1"), a team folder, a folder in the demo student's own files, and a teacher's file shared read-only with the student. The student is added to the adopted team so the team files are visible from the account under test. Every file carries a unique search term, so search over the index can be checked per area.

Legacy file uploads only work because of two changes to the stack:

  • minio-loopback. The legacy service signs upload and download URLs for its storage provider's single endpoint, and uses that endpoint for its own S3 calls. minio:9000 works inside the compose network and nowhere else, so the browser and the MCP server on the host were handed URLs they could not open. seed.sh now registers http://localhost:9900, and this socat sidecar, sharing the api container's network namespace, forwards that address to MinIO — the same url then works from the api, the browser and the host. After recreating api, recreate minio-loopback too: it lives in api's network namespace.
  • The school bucket is created up front. The legacy service makes bucket-<schoolId> on first upload and then calls PutBucketCors, which MinIO does not implement, so the first upload failed with "A header you provided implies functionality that is not implemented". minio-init.sh creates the demo school's bucket, and an existing bucket skips both calls.

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              # 79 checks against the local instance

mcp-env.sh points the index at its own database, schulcloud_local, and the mirror at tmp/mirror-local — not just the instance at this one. The root .env normally targets the live account, and process env beats --env-file, so without that a local smoke run would crawl these fixtures into the live index, where a per-course refresh then carries them forward indefinitely. Create the database once:

docker exec schulcloud-mcp-db psql -U schulcloud -d postgres \
  -c "CREATE DATABASE schulcloud_local OWNER schulcloud"

Without it the server does not fail; it runs live-only, and smoke reports the smaller, index-free check count.

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.

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.