From ccbf3ad3e9deb836f7d2eec0d85c9dffb8ce5701 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 17 Sep 2026 20:43:24 +0200 Subject: [PATCH] Document WebUntis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 81 ++++++++++++++++++++++++++++++++++------ README.md | 42 ++++++++++++++++----- docs/API.md | 69 ++++++++++++++++++++++++++++++++++ docs/AUTH.md | 32 ++++++++++++++++ docs/DEPLOYMENT.md | 20 ++++++++++ docs/LOCAL.md | 4 +- docs/PI.md | 16 ++++++++ local-instance/README.md | 6 ++- 8 files changed, 245 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9cc3c36..5e9e7e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) 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`. Three entry points over one core: @@ -45,9 +47,10 @@ index. read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`, `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 -without (77 checks, live-only). The degradation path is a supported mode, not a -fallback nobody exercises. Every Schulcloud check fails with 401 when the live +Run smoke **both ways**: with `DATABASE_URL` set (89 checks, index-backed) and +without (87 checks, live-only); without a WebUntis key both drop by 9, and the +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. 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 - services.ts (process-wide: client, Store, Indexer) + services.ts (process-wide: client, Store, Indexer, UntisClient) │ indexer/indexer.ts ── store/store.ts ── Postgres │ 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. @@ -84,6 +88,16 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync - `session-token.ts` — the Schulcloud token, replaceable at runtime: checked 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.** + - **`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 `MCP_CONNECTOR_TOKEN`, the request header claude.ai stores, which must never open `/api`. Besides those: the optional `//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 a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say 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 person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind - `get_course`/`get_room`; and two prompts. What people read in a picker (labels, - prompt texts) is German; what the model reads stays English. + `get_course`/`get_room`; and three prompts, the third being + `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 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 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 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 @@ -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. **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. 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** — @@ -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 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 - 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 -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 says when to use it *and when not to*. 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 - 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. ## Resources and prompts @@ -339,7 +396,9 @@ bundle (2.1.272), not its docs: ## Environment `.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`, no restart. `npm run probe` and `schulcloud token` report the clocks: days until hard expiry and the session budget. diff --git a/README.md b/README.md index 00894c8..a95a4c6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # schulcloud-mcp 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 -**you**, via a CLI that mirrors your coursework to disk. +courses, boards, lessons, tasks and files — plus the timetable from +[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 alive on a Pi. @@ -16,8 +17,9 @@ instance, not inferred from the upstream source. > *"What do I have due this week?"* > *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."* > *"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_news` | school and course announcements | | `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** files and returns **images inline** for Claude to look at. Image-only PDFs — @@ -56,17 +61,20 @@ MCP resource (`schulcloud://courses/`, `schulcloud://rooms/`) holding the same overview `get_course` and `get_room` return. In Claude Code, type `@` 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 | | `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 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 @@ -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 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, so `MCP_CONNECTOR_TOKEN` opens `/mcp` and nothing else — it is refused on `/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/ - 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 indexer/ crawl → persist → mirror bytes → extract text → index 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, -the traversal every feature needs, document extraction, and the path -sanitisation that both the server's mirror and the CLI's sync depend on. +the traversal every feature needs, document extraction, the WebUntis client with +its one-time codes, and the path sanitisation that both the server's mirror and +the CLI's sync depend on. ## Development @@ -186,7 +208,7 @@ npm run typecheck ``` `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 extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling. diff --git a/docs/API.md b/docs/API.md index 8bdccbd..d509b89 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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=&school=&v=i3.2`, + with `{"jsonrpc":"2.0","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 diff --git a/docs/AUTH.md b/docs/AUTH.md index 1bf7f94..c0b11ce 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -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` diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 73df5b2..5bb7433 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -196,6 +196,26 @@ claude mcp add --transport http --scope user schulcloud https://mcp.example.org/ schulcloud login --server https://mcp.example.org --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 diff --git a/docs/LOCAL.md b/docs/LOCAL.md index 5e9984b..d1a710f 100644 --- a/docs/LOCAL.md +++ b/docs/LOCAL.md @@ -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: diff --git a/docs/PI.md b/docs/PI.md index 48f27b2..0040533 100644 --- a/docs/PI.md +++ b/docs/PI.md @@ -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 diff --git a/local-instance/README.md b/local-instance/README.md index cfb3777..1d80b03 100644 --- a/local-instance/README.md +++ b/local-instance/README.md @@ -217,11 +217,13 @@ be pointed at the instance in between: ```bash 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 -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 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