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)
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 `/<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
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.