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

@@ -6,7 +6,9 @@ Guidance for Claude Code when working in this repository.
Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software) Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software)
account: courses, column boards, lessons, tasks, files with text extraction, and account: courses, column boards, lessons, tasks, files with text extraction, and
a Postgres-backed full-text index. TypeScript, Node 22+, a Postgres-backed full-text index — plus the **timetable from WebUntis**, which
is a separate system and the only place this school publishes when a lesson
happens, or that it was cancelled. TypeScript, Node 22+,
`@modelcontextprotocol/sdk`. `@modelcontextprotocol/sdk`.
Three entry points over one core: Three entry points over one core:
@@ -45,9 +47,10 @@ index.
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`, read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
`src/mcp/` or `src/http/` — the unit tests cover only pure functions. `src/mcp/` or `src/http/` — the unit tests cover only pure functions.
Run smoke **both ways**: with `DATABASE_URL` set (79 checks, index-backed) and Run smoke **both ways**: with `DATABASE_URL` set (89 checks, index-backed) and
without (77 checks, live-only). The degradation path is a supported mode, not a without (87 checks, live-only); without a WebUntis key both drop by 9, and the
fallback nobody exercises. Every Schulcloud check fails with 401 when the live run then asserts the `untis_*` tools are *not* offered. The degradation paths are
supported modes, not fallbacks nobody exercises. Every Schulcloud check fails with 401 when the live
session has lapsed — check the container's keepalive log before suspecting code. session has lapsed — check the container's keepalive log before suspecting code.
Store tests need a database and skip without one: Store tests need a database and skip without one:
@@ -65,11 +68,12 @@ bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*, mcp/{resources,p
bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts
services.ts (process-wide: client, Store, Indexer) services.ts (process-wide: client, Store, Indexer, UntisClient)
indexer/indexer.ts ── store/store.ts ── Postgres indexer/indexer.ts ── store/store.ts ── Postgres
core/{client,board,crawl,extract,text,paths,types} core/{client,board,crawl,extract,text,paths,types}
core/{untis,totp,dates} ── WebUntis, a second upstream
``` ```
- **`core/`** knows nothing of MCP, HTTP or the CLI. - **`core/`** knows nothing of MCP, HTTP or the CLI.
@@ -84,6 +88,16 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked - `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.** `STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
- **`untis.ts`** — WebUntis, the second upstream: the timetable with its
cancellations and substitutions, class-register homework and lesson topics.
Authenticates each request with a one-time code (`totp.ts`) over the key
from Profil → Freigaben, so there is no session and no keepalive on this
side. Resolves the payload's element ids to names and returns every day in
a range, empty ones included. See Invariants for why the allowlist is there.
- `dates.ts` — school days as `YYYY-MM-DD` in Europe/Berlin. The container
runs UTC, so `schoolToday()` is not `new Date()`: at 00:30 in Erfurt the
process clock still says yesterday, and a nightly briefing would prepare
the wrong day.
- **`http/`** — `/mcp` and `/api` take `MCP_AUTH_TOKEN`; `/mcp` alone also takes - **`http/`** — `/mcp` and `/api` take `MCP_AUTH_TOKEN`; `/mcp` alone also takes
`MCP_CONNECTOR_TOKEN`, the request header claude.ai stores, which must never `MCP_CONNECTOR_TOKEN`, the request header claude.ai stores, which must never
open `/api`. Besides those: the optional `/<secret>/mcp` for clients without open `/api`. Besides those: the optional `/<secret>/mcp` for clients without
@@ -101,10 +115,15 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- **`mcp/tools/*.ts`** — tool descriptions are prompts: they are how Claude picks - **`mcp/tools/*.ts`** — tool descriptions are prompts: they are how Claude picks
a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say
when *not* to use the tool. when *not* to use the tool.
- **`mcp/tools/untis.ts`** — the `untis_*` tools, registered **only** when
`UNTIS_*` is configured: a tool that can only fail is worse than a missing
one. `readTimetable` is shared with the prompt, the way `readCourse` is.
- **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a - **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a
person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind
`get_course`/`get_room`; and two prompts. What people read in a picker (labels, `get_course`/`get_room`; and three prompts, the third being
prompt texts) is German; what the model reads stays English. `tagesvorbereitung`, which attaches a day's timetable and is the point where
the two systems meet. What people read in a picker (labels, prompt texts) is
German; what the model reads stays English.
- **`context.ts`** — per-session state. Only `/me` is cached, because the school - **`context.ts`** — per-session state. Only `/me` is cached, because the school
id is on every files-storage path and cannot change for a token. id is on every files-storage path and cannot change for a token.
@@ -123,6 +142,15 @@ neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POS
own index and mirror, and `PUT /api/token` only to the server's own token — every own index and mirror, and `PUT /api/token` only to the server's own token — every
upstream call they make is still a GET. upstream call they make is still a GET.
**WebUntis is read-only by allowlist, not by verb.** Its API is JSON-RPC, so
every call is a POST, reads included — "GET only" cannot carry over. Instead
`core/untis.ts` holds `READ_METHODS` and `assertReadMethod` refuses anything
else at the single choke point, which `test/untis.test.ts` asserts. This matters
because the key is the mobile app's credential and can do what the app can: the
live account's rights include `W_OWN_ABSENCE`, i.e. that key could report the
user absent. Add a method only after reading what it does upstream, and never
one whose name starts with `submit`, `create`, `save` or `delete`.
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles **Filenames from Schulcloud are untrusted paths.** Course titles, card titles
and filenames are all user-supplied upstream, and both the server's mirror and and filenames are all user-supplied upstream, and both the server's mirror and
the CLI's sync turn them into filesystem paths. Everything goes through the CLI's sync turn them into filesystem paths. Everything goes through
@@ -135,7 +163,9 @@ the property that makes that acceptable. Do not add a write tool without the
user explicitly asking for one and understanding this. user explicitly asking for one and understanding this.
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the **Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
account; `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` guard the endpoint. None belongs in account; `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` guard the endpoint;
`UNTIS_SECRET` authenticates as the user in WebUntis and outlives every other
credential here, since it does not expire. None belongs in
logs, error messages, or tool output. `.env` is git-ignored — keep it that way. logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
Two more count as secrets: a token replaced at runtime (it lives only in Two more count as secrets: a token replaced at runtime (it lives only in
`STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths** `STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths**
@@ -291,6 +321,30 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
the tab. This produced two false conclusions before being found — if a token the tab. This produced two false conclusions before being found — if a token
dies ~2h after login, suspect an open tab first. `docs/AUTH.md` has the chain. dies ~2h after login, suspect an open tab first. `docs/AUTH.md` has the chain.
WebUntis has its own set; `docs/API.md` has them all, these are the ones that
cost the most:
- **`jsonrpc_intern.do` needs `?v=i3.2`.** Without it the call fails with
`-8998` wrapping a Java NullPointerException, which reads like a malformed
body and is not. Errors also arrive with **HTTP 200** and an `error` member,
so check the body before the status.
- **The one-time code travels as a string.** One code in ten begins with a
zero, and a JSON number drops it — a login that fails 10% of the time, which
is the worst kind of failure to debug.
- **`startDateTime` ends in `Z` and is local time.** `2026-09-21T08:00Z` is the
08:00 lesson in Erfurt. Never hand these to `new Date`; `splitLocal` takes
the string apart. Every date the tools send is computed in Europe/Berlin
(`core/dates.ts`), because the container is UTC.
- **A substitution is two periods**: the original with `is: ["CANCELLED"]` and
the replacement beside it with `is: ["IRREGULAR"]`. A lesson is not "changed
in place", so both have to be read, and `orgId` on an element exists as well.
- **Announced tests live in `text.info`**, not in the exam module — this school
does not use it, so `getExams2017` is always empty. That field is the most
valuable thing in the payload.
- **A day with no lessons is not a holiday.** Vocational school weeks spent at
the company simply have no periods, and `holidays` says nothing about them.
Do not report "Ferien" for them; say there are no lessons.
## Conventions ## Conventions
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc` - Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
@@ -308,12 +362,15 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
## Adding a tool ## Adding a tool
1. Add the client method in `core/client.ts` (`GET` only). 1. Add the client method in `core/client.ts` (`GET` only) — or, for the
timetable side, in `core/untis.ts`, whose method name must go in
`READ_METHODS` and must read.
2. Register the tool in the relevant `mcp/tools/*.ts`, with a description that 2. Register the tool in the relevant `mcp/tools/*.ts`, with a description that
says when to use it *and when not to*. says when to use it *and when not to*.
3. Format output as Markdown, keeping ids visible for follow-up calls. 3. Format output as Markdown, keeping ids visible for follow-up calls.
4. If it reads the index, handle `context.store === undefined` with a message 4. If it reads the index, handle `context.store === undefined` with a message
saying what is unavailable and what still works. saying what is unavailable and what still works. A `untis_*` tool instead
registers only when `context.untis` exists.
5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways. 5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways.
## Resources and prompts ## Resources and prompts
@@ -339,7 +396,9 @@ bundle (2.1.272), not its docs:
## Environment ## Environment
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally `.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
`MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`; docker-compose sets `STATE_DIR`. See `.env.example` for the `MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`, plus the four `UNTIS_*` values (all
four or none — a half-filled block is a paste that went wrong, so it throws);
docker-compose sets `STATE_DIR`. See `.env.example` for the
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`, full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
no restart. `npm run probe` and `schulcloud token` report the clocks: days until no restart. `npm run probe` and `schulcloud token` report the clocks: days until
hard expiry and the session budget. hard expiry and the session budget.

View File

@@ -1,8 +1,9 @@
# schulcloud-mcp # schulcloud-mcp
Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account — Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account —
courses, boards, lessons, tasks and files — for **Claude**, via MCP, and for courses, boards, lessons, tasks and files — plus the timetable from
**you**, via a CLI that mirrors your coursework to disk. [WebUntis](https://www.untis.at/), for **Claude**, via MCP, and for **you**, via
a CLI that mirrors your coursework to disk.
Both are front ends over one core library and one live Schulcloud session, kept Both are front ends over one core library and one live Schulcloud session, kept
alive on a Pi. alive on a Pi.
@@ -16,8 +17,9 @@ instance, not inferred from the upstream source.
> *"What do I have due this week?"* > *"What do I have due this week?"*
> *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."* > *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."*
> *"Summarise the routing lesson from the LF10 course."* > *"Summarise the routing lesson from the LF10 course."*
> *"What do I have tomorrow, and has anything been cancelled?"*
Twenty-four tools, all read-only: Twenty-seven tools, all read-only:
| | | | | |
|---|---| |---|---|
@@ -45,6 +47,9 @@ Twenty-four tools, all read-only:
| `list_classes` | classes ("Klassen") with their teachers, and group membership | | `list_classes` | classes ("Klassen") with their teachers, and group membership |
| `list_news` | school and course announcements | | `list_news` | school and course announcements |
| `api_get` | GET-only escape hatch for uncovered API surface | | `api_get` | GET-only escape hatch for uncovered API surface |
| `untis_timetable` | the school day from **WebUntis**: lessons, Entfall, Vertretung, room changes, period notes |
| `untis_homework` | homework from WebUntis' class register — a separate list from Schulcloud's tasks |
| `untis_lesson_topics` | what previous lessons of a subject actually covered ("Unterrichtsinhalt") |
`download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument** `download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument**
files and returns **images inline** for Claude to look at. Image-only PDFs — files and returns **images inline** for Claude to look at. Image-only PDFs —
@@ -56,17 +61,20 @@ MCP resource (`schulcloud://courses/<id>`, `schulcloud://rooms/<id>`) holding
the same overview `get_course` and `get_room` return. In Claude Code, type `@` the same overview `get_course` and `get_room` return. In Claude Code, type `@`
and part of the course name. and part of the course name.
**Two ready-made prompts**, in German because the school is: **Three ready-made prompts**, in German because the school is:
| | | | | |
|---|---| |---|---|
| `zusammenfassung` *kurs* [*fokus*] | summarise a course or room: topics, tasks, key material, each with its source | | `zusammenfassung` *kurs* [*fokus*] | summarise a course or room: topics, tasks, key material, each with its source |
| `pruefungsvorbereitung` *kurs* [*thema*] [*datum*] | prepare for an exam: scope, explanations, practice questions, a study plan | | `pruefungsvorbereitung` *kurs* [*thema*] [*datum*] | prepare for an exam: scope, explanations, practice questions, a study plan |
| `tagesvorbereitung` [*tag*] | prepare a school day: the lessons from WebUntis, what is new in those courses, what is due |
In Claude Code they run as `/mcp__schulcloud__zusammenfassung Mathe_10b`. In Claude Code they run as `/mcp__schulcloud__zusammenfassung Mathe_10b`, or
`/mcp__schulcloud__tagesvorbereitung morgen` the evening before.
Claude Code splits arguments on spaces and drops extra words, so join words Claude Code splits arguments on spaces and drops extra words, so join words
with `_` (`Lineare_Funktionen`) and skip an optional argument with `-`. *kurs* with `_` (`Lineare_Funktionen`) and skip an optional argument with `-`. *kurs*
is any unambiguous part of a course or room name, or its id. is any unambiguous part of a course or room name, or its id; *tag* takes
`heute`, `morgen`, `übermorgen`, `21.09.2026` or `2026-09-21`.
## The CLI ## The CLI
@@ -116,6 +124,19 @@ copy is the browser's own session token**, so a Schulportal tab left open will
auto-logout after ~2 hours and revoke this server's token with it. Copy the auto-logout after ~2 hours and revoke this server's token with it. Copy the
token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md). token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md).
**The timetable comes from somewhere else.** Schulcloud holds the material for
a lesson but not the lesson: this school's course `times` are empty and it
publishes its schedule in **WebUntis**. So the server reads that too, through
the API the Untis Mobile app uses — which authenticates with a key from Profil →
Freigaben and a time-based code per request, meaning no password, no session to
hold open and nothing that expires monthly. The two systems answer different
halves of the same question, and the German `tagesvorbereitung` prompt is where
they meet: which lessons happen today, what changed in their courses, what is
due. That key *can* write (the app may report an absence), so this side is kept
read-only by an allowlist of five read methods rather than by "GET only". Unset
`UNTIS_*` and none of it exists — the tools are not even offered. See
[docs/AUTH.md](docs/AUTH.md).
**claude.ai gets a token of its own.** Its connector stores a request header, **claude.ai gets a token of its own.** Its connector stores a request header,
so `MCP_CONNECTOR_TOKEN` opens `/mcp` and nothing else — it is refused on so `MCP_CONNECTOR_TOKEN` opens `/mcp` and nothing else — it is refused on
`/api`, which can replace the Schulcloud token — and rotates without touching `/api`, which can replace the Schulcloud token — and rotates without touching
@@ -155,7 +176,7 @@ bypass, "what's new since…" — are sketched with their trade-offs in
``` ```
src/ src/
core/ client, types, board assembly, crawler, extraction, paths core/ client, types, board assembly, crawler, extraction, paths, WebUntis
store/ Postgres: crawl generations, diffs, full-text search store/ Postgres: crawl generations, diffs, full-text search
indexer/ crawl → persist → mirror bytes → extract text → index indexer/ crawl → persist → mirror bytes → extract text → index
mcp/ MCP server, tools, resources and prompts mcp/ MCP server, tools, resources and prompts
@@ -169,8 +190,9 @@ vendor/ upstream clones, git-ignored, for reference only
``` ```
`core/` knows nothing about MCP, HTTP or the CLI: it holds the Schulcloud client, `core/` knows nothing about MCP, HTTP or the CLI: it holds the Schulcloud client,
the traversal every feature needs, document extraction, and the path the traversal every feature needs, document extraction, the WebUntis client with
sanitisation that both the server's mirror and the CLI's sync depend on. its one-time codes, and the path sanitisation that both the server's mirror and
the CLI's sync depend on.
## Development ## Development
@@ -186,7 +208,7 @@ npm run typecheck
``` ```
`npm run smoke` starts the HTTP server, connects a real MCP client over `npm run smoke` starts the HTTP server, connects a real MCP client over
Streamable HTTP and exercises every tool against the live account — 77 checks (79 with the index) Streamable HTTP and exercises every tool against the live account — 87 checks (89 with the index, 9 fewer without a WebUntis key)
covering the auth gate, the connector token and the secret path, the protocol handshake, every content chain, file covering the auth gate, the connector token and the secret path, the protocol handshake, every content chain, file
extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling. extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling.

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 me" query reads `refPermModel`, so a share made through it never appears
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly. 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 ## Re-verifying after an upstream release
`npm run probe` re-checks every assumption above against the live instance and `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 … [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 ## Why not username + password
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri` 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> 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 ## Replacing the Schulcloud token
The token lasts 30 days at most and can only come from a browser login (see 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 ## Run the test suites
```bash ```bash
npm test # 184 offline tests npm test # 224 offline tests
npm run smoke # end-to-end against the live instance, live-only mode 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: Store tests need a database and skip without one:

View File

@@ -88,6 +88,19 @@ INDEX_PERSONAL_FILES=true
EOF 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: What those lines do:
| Setting | | | Setting | |
@@ -98,6 +111,7 @@ What those lines do:
| `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. | | `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. | | `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. | | `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. | | `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 **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` | | `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 | | `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) | | 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` | | `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 ## Security checklist

View File

@@ -217,11 +217,13 @@ be pointed at the instance in between:
```bash ```bash
eval "$(./scripts/mcp-env.sh)" # as the demo student eval "$(./scripts/mcp-env.sh)" # as the demo student
cd .. && npm run smoke # 79 checks against the local instance cd .. && npm run smoke # 80 checks against the local instance
``` ```
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the `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 mirror at `tmp/mirror-local` — not just the instance at this one, and it switches
WebUntis off, since this instance has no timetable and the key in the root `.env`
is the real school's. The root
`.env` normally targets the live account, and process env beats `--env-file`, so `.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, 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 where a per-course refresh then carries them forward indefinitely. Create the