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:
81
CLAUDE.md
81
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 `/<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.
|
||||
|
||||
Reference in New Issue
Block a user