Document WebUntis

docs/API.md gains the findings: the endpoint and its required version
parameter, the one-time code and the two error codes worth naming, the Z that
means local time, a substitution being two periods, the unused exam module
that puts announced tests in the period notes, and a day without lessons that
is not a holiday. Those cost an afternoon of probing to learn and nothing
upstream states them.

docs/AUTH.md sets the key against the Schulcloud token it sits beside: no
password, nothing to keep alive, revocable on its own, and not read-only in
itself — which is why the allowlist exists. PI.md and DEPLOYMENT.md add the
four values, the container recreate a changed key needs, and the clock
requirement; the Pi's troubleshooting table gains both failure messages.

README and CLAUDE.md say what the server now is: Schulcloud for the material,
WebUntis for the day. Smoke is 89 checks with the index and a key, 87
live-only, 9 fewer without one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-17 20:43:24 +02:00
parent eaf9c7aa38
commit ccbf3ad3e9
8 changed files with 245 additions and 25 deletions

View File

@@ -288,6 +288,75 @@ Two more, seen while building the local fixture:
me" query reads `refPermModel`, so a share made through it never appears
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly.
## WebUntis is a fourth store, and holds the timetable
Schulcloud's `times` on `/api/v1/courses` are empty for this school, so nothing
in Schulcloud says when a lesson happens, let alone that it was cancelled. That
lives in **WebUntis**, a separate product with its own login. What this server
uses is the API the Untis Mobile app uses, verified against
`ags-erfurt.webuntis.com` on 2026-09-17.
- **One endpoint, JSON-RPC:** `POST /WebUntis/jsonrpc_intern.do?m=<method>&school=<school>&v=i3.2`,
with `{"jsonrpc":"2.0","method":<method>,"params":[{…, "auth":{…}}]}`.
- **`v` is not optional.** Omit it and the call fails with `-8998` carrying a
Java `NullPointerException` from `getParameter`, which reads like a bug in
the request body and is not.
- Errors arrive with **HTTP 200** and an `error` member, so the body has to be
checked before the status.
- A plain `user-agent: schulcloud-mcp` is accepted; there is no need to
impersonate the app.
- **Authentication is a TOTP over a static base32 key**, no password and no
session: `auth: { user, otp, clientTime }` on every call, where `otp` is the
6-digit RFC 6238 code for the key from Profil → Freigaben → Untis Mobile.
- **Send the code as a string.** One in ten starts with a zero, which a JSON
number silently drops. Both shapes are accepted, so only the string is
always right.
- `-8504 bad credentials` = wrong key or user. `-8524 invalid client time` =
the host's clock is off, which is its own failure and worth naming.
- The response sets a `JSESSIONID`, but nothing needs it: each request
authenticates itself, which is why there is no keepalive on this side.
- **Methods that exist** (and are all this server may call — see the allowlist
in `core/untis.ts`): `getUserData2017`, `getTimetable2017`,
`getLessonTopic2017`, `getHomeWork2017`, `getMessagesOfDay2017`.
`getClassregEvents2017` and `getSchoolyears2017` answer "Method not found";
`getPeriodData2017` answers with empty objects for a student.
- **`startDateTime` claims to be UTC and is not.** Lessons come back as
`2026-09-21T08:00Z` and the school's time grid starts at 08:00 local, so the
`Z` is decoration. `new Date(...)` would move every lesson by an hour or two,
twice a year by a different amount; `splitLocal` takes the string apart
instead.
- **A substitution is two periods, not one changed period.** The original turns
up with `is: ["CANCELLED"]` and the replacement beside it with
`is: ["IRREGULAR"]`, same slot, different teacher. `orgId` on an element also
exists (the room moves that way), so both have to be read. Statuses seen on a
real account: `REGULAR`, `CANCELLED`, `IRREGULAR`.
- **`getTimetable2017` carries the whole master data with every answer** —
subjects, teachers with full names, rooms, classes, every holiday of six
school years. There is a `masterDataTimestamp` delta protocol; its removal
semantics are unverified, so this server asks for the full set and caches it
for a few hours.
- **Homework hangs off the periods too.** `getHomeWork2017` filters by the
homework's own dates, so a window ending today shows nothing due tomorrow and
nothing set last month — and the same items appear inline on the timetable's
periods, where they need no second call.
- **`getLessonTopic2017` takes `periodId`, singular.** It answers with
`previousTopics`: what the earlier lessons of that series actually covered,
from the class register. A `periodIds` array is rejected as "period 0 not
found".
- **The exam module is unused at this school**, so `getExams2017` is empty and
`period.exam` is null. Announced tests are typed into the period's **info
text** instead ("LF10: Leistungskontrolle agile Softwareentwicklung …"), which
makes `text.info` the most valuable field in the payload rather than a
footnote.
- **A day with no lessons is not necessarily a holiday.** At a vocational school
the weeks spent in the company simply have no periods, and `holidays` says
nothing about them. Reporting "Ferien" there would be wrong; so would an
empty answer.
- **The key can write.** This account's `rights` are `CLASSREGISTER`,
`R_MY_ABSENCES`, `W_OWN_ABSENCE`, `R_OFFICEHOURS` — the mobile API can report
an absence for the user. Nothing here does, and the allowlist is what
guarantees it.
## Re-verifying after an upstream release
`npm run probe` re-checks every assumption above against the live instance and

View File

@@ -162,6 +162,38 @@ says so immediately:
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
```
## WebUntis: a second credential, of a different kind
The timetable is not in Schulcloud, so the server talks to WebUntis as well —
and that side authenticates far more comfortably. In WebUntis, open **Profil →
Freigaben → Untis Mobile → QR-Code**. The dialog shows four values, which go
into `.env` as `UNTIS_SERVER` (its "Url"), `UNTIS_SCHOOL`, `UNTIS_USER` and
`UNTIS_SECRET` (its "Schlüssel"). The school number shown there is not used.
Why this is the right credential for a server:
- **No password.** The key is what the Untis Mobile app is given, and it works
even where the WebUntis login goes through the school's SSO.
- **Nothing to keep alive.** Every request carries a fresh time-based code
derived from the key, so there is no session to refresh and nothing that dies
when the Pi is off for a day. Unlike the Schulcloud token, this needs no
monthly chore: the key stays valid until you replace it.
- **Revocable on its own.** Generating a new key in that dialog invalidates the
old one, and it has nothing to do with your password.
Two things to know:
- **The host's clock matters.** A time-based code from a drifting clock is
refused with `-8524 invalid client time`, which the tools report as such. Any
Pi with working NTP is fine.
- **The key is not read-only — the server is.** It can do what the app can, and
on this account that includes reporting an absence (`W_OWN_ABSENCE`). Untis'
API is JSON-RPC, where reads are POSTs too, so "GET only" cannot be the
guarantee here as it is for Schulcloud. Instead `core/untis.ts` carries an
allowlist of five read methods and refuses everything else; `test/untis.test.ts`
holds it to that. Replacing `UNTIS_SECRET` needs a restart — there is no live
swap for it, because it does not expire.
## Why not username + password
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri`

View File

@@ -196,6 +196,26 @@ claude mcp add --transport http --scope user schulcloud https://mcp.example.org/
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
```
## The WebUntis key
The timetable comes from WebUntis, which needs four values in `.env`
`UNTIS_SERVER`, `UNTIS_SCHOOL`, `UNTIS_USER`, `UNTIS_SECRET` — all from WebUntis
→ Profil → Freigaben → Untis Mobile → QR-Code. With them the server offers the
`untis_*` tools and the `tagesvorbereitung` prompt; without them it does not,
and nothing else changes.
Operationally it is the easy credential: no password, it survives the container
being off, and it expires only when you generate a new key. Two consequences
for a deployment:
- **Recreate the container after changing it** (`docker compose up -d
--force-recreate schulcloud-mcp`) — `env_file` is read at creation, and unlike
the Schulcloud token there is no live swap, because there is no monthly chore
to spare anyone.
- **Keep the host's clock synchronised.** Requests are signed with a
time-based code; a drifting clock is refused with "invalid client time", which
the tools report in those words.
## Replacing the Schulcloud token
The token lasts 30 days at most and can only come from a browser login (see

View File

@@ -107,9 +107,9 @@ node dist/bin/cli.js sync
## Run the test suites
```bash
npm test # 184 offline tests
npm test # 224 offline tests
npm run smoke # end-to-end against the live instance, live-only mode
DATABASE_URL=… npm run smoke # end-to-end with the index (79 checks)
DATABASE_URL=… npm run smoke # end-to-end with the index (89 checks with a WebUntis key)
```
Store tests need a database and skip without one:

View File

@@ -88,6 +88,19 @@ INDEX_PERSONAL_FILES=true
EOF
```
If your school publishes its timetable in **WebUntis**, add those four values
too — the timetable, its cancellations and substitutions are not in Schulcloud
at all. They are in WebUntis under Profil → Freigaben → Untis Mobile → QR-Code:
```bash
cat >> .env <<'EOF'
UNTIS_SERVER=yourschool.webuntis.com
UNTIS_SCHOOL=yourschool
UNTIS_USER=your.username
UNTIS_SECRET=THEKEYFROMTHEQRDIALOG
EOF
```
What those lines do:
| Setting | |
@@ -98,6 +111,7 @@ What those lines do:
| `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. |
| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
| `UNTIS_*` | WebUntis, where the school keeps the timetable. All four or none; the key needs no password and does not expire. See [AUTH.md](AUTH.md). Optional. |
| `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. |
**Copy `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` into your password manager
@@ -415,6 +429,8 @@ Going back works the same way: set the previous commit id, then `pull` and
| `unauthorized` or `pull access denied` from `docker compose pull` | Not logged in to the registry on the Pi | `docker login registry.mc02.dev` |
| `no matching manifest for linux/arm64` | The image was published for amd64 only | Publish again with `npm run publish-image`, which builds both |
| Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) |
| `WebUntis rejected the server's key` from a untis_* tool | The key was regenerated in WebUntis, or `UNTIS_USER` does not match it | Copy both again from Profil → Freigaben → Untis Mobile, then `docker compose up -d --force-recreate schulcloud-mcp` |
| `the server's clock is too far off` from a untis_* tool | The Pi's clock has drifted; the Untis code is time-based | `timedatectl status`, then fix NTP |
| `EACCES` for `/data/state` or `/data/mirror` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`), `docker compose up -d` |
## Security checklist