Read the user's own lesson notes, and the class register behind them

Schulcloud says what was uploaded and WebUntis says what was scheduled.
Neither says what was *taught* — which point the teacher laboured, which
example landed, what "will definitely come up". That lives in two places
this server could not reach: the notes the user takes in the lesson, and
WebUntis' class register.

Notes are a directory of Markdown files (NOTES_DIR), not a table. They
have to be writable from a phone in a classroom, readable when Postgres
is down, and outlive this project, and files are the only shape that is
all three — so the files are the truth and the index is a view of them,
the same split as file_texts and the mirror. list_notes and get_note read
disk, so they answer before the first crawl; search, what_changed and all
three German prompts read them alongside the Schulcloud material.

add_note writes one, and is the only thing in this server that writes
anything. That is not a hole in the read-only invariant but a different
store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin
pair that stops a hostile Schulcloud filename escaping the mirror, so a
note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and
WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY
refuses writes outright.

Appending targets the *lesson*, not the title: "halt das auch noch fest"
mid-lesson carries a new title, and deriving the path from it would start
a second note every time, which is the one thing append exists to prevent.

Notes.app has no export — its bodies are compressed protobuf and the
iCloud copy is encrypted — so scripting the app is not the clumsy route
to the notes but the only one. scripts/export-apple-notes.js reads them
through AppleScript into one JSON object per line, and `schulcloud note
import` converts the HTML to Markdown, takes the Notes folder as the
subject and the *creation* date as the lesson's date. Attachments cannot
come across; a note that was a photo of the board imports as a line
saying so, because importing it empty would hide the loss.

The class register needed one API property to become cheap:
getLessonTopic2017 answers per *series*, not per period, so a term is
reconstructed by asking about the latest period of each lesson series and
merging back by id — a few dozen calls for a school year rather than one
per lesson. untis_lesson_topics now takes a subject as well as a period
id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind
of its own, so "what did we actually do before the test" is searchable.

Sharing the snapshot rather than duplicating it caught one thing on the
way: the search tool's live path had to learn notes too, or fresh=true
would have quietly disagreed with the index.

305 tests; 88/89 smoke against the local instance, the one failure being
the H5P service that instance does not run. The live smoke could not be
retaken: that session has lapsed and needs a fresh jwt cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-18 21:45:39 +02:00
parent ad8ba28313
commit af4464decb
34 changed files with 3078 additions and 61 deletions

View File

@@ -8,8 +8,14 @@ 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 — 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`.
happens, or that it was cancelled, and **the user's own lesson notes**, a
directory of Markdown files that is the only record of what was actually said in
the room. TypeScript, Node 22+, `@modelcontextprotocol/sdk`.
Three sources, and the distinction matters in every tool description: Schulcloud
has the material, WebUntis has the schedule and the class register, the notes
have what the teacher stressed. An answer that silently merges them is worse
than one that says which said what.
Three entry points over one core:
- `src/bin/http.ts` — Streamable HTTP + `/api`, the deployed form, behind Caddy on a Pi.
@@ -47,10 +53,18 @@ 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 (93 checks, index-backed) and
without (91 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
Run smoke **both ways**: with `DATABASE_URL` set (index-backed) and without
(live-only). Without a WebUntis key the run asserts the `untis_*` tools are
*not* offered instead of exercising them, and the same holds for `NOTES_DIR`
except that the smoke sets its own throwaway one, so the note tools are always
exercised and can never touch real notes. The degradation paths are supported
modes, not fallbacks nobody exercises.
The check counts are a tripwire, so re-measure them rather than trusting this
line after a change: **88/89 against the local instance** on 2026-09-18 (the one
failure is the H5P service, which that instance does not run). The live counts
are stale — they were last taken before the notes and class-register work, and
could not be retaken because the live session had lapsed. 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:
@@ -94,6 +108,16 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
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.
- **`notes.ts`** — the user's own lesson notes as a directory of Markdown
files with a small frontmatter dialect. The files are the truth and the
index is a view of them, so `list_notes`/`get_note` read disk and answer
before the first crawl and while Postgres is down. **The one thing anything
here writes** — see Invariants. `docs/NOTES.md` is the guide.
- **`untis-history.ts`** — the class register read backwards, which is what
puts "what did we actually cover" into the search index. Its whole reason
for existing is one API property: `getLessonTopic2017` answers per *series*,
so a term costs one call per lesson series rather than one per period — see
API gotchas.
- `h5p.ts` — the quizzes on a board. One GET per element, parsed into
questions and answers; board assembly attaches it like a pad, the crawl
indexes its text, and `get_h5p` prints it.
@@ -121,6 +145,15 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- **`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.
`untis_lesson_topics` takes **either** a `periodId` (one series) **or** a
`subject` (a whole term, via `untis-history.ts`); neither and both are both
refused, because guessing which was meant is worse than asking.
- **`mcp/tools/notes.ts`** — `list_notes`, `get_note` and `add_note`, registered
**only** when `NOTES_DIR` is set, by the same rule as the `untis_*` tools.
- **`mcp/prompts.ts` takes a `Sources` flag** (`{ notes, untis }`) so a prompt
never tells Claude to call a tool this deployment does not register. A prompt
built with no sources names none of them — that is the default, and the safe
one.
- **`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 three prompts, the third being
@@ -145,6 +178,17 @@ 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.
**The notes directory is the only thing anything here writes to.** That is not
an exception to the invariant above — it is a different store: the user's own
files, never Schulcloud and never WebUntis. It is bounded by the same two
functions as the file mirror, and for the same reason: `add_note`'s title and
subject arrive from a tool call, become path components through `safeComponent`,
and the result is checked by `resolveWithin`, so a note titled
`../../.ssh/authorized_keys` becomes a filename. `NOTES_READONLY` refuses writes
entirely. Do not widen this to anything outside `NOTES_DIR`, and do not take it
as precedent for a Schulcloud write tool — that decision is the one above, and
it has not changed.
**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
@@ -354,6 +398,12 @@ cost the most:
- **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.
- **`getLessonTopic2017` answers per *series*, not per period.** Its
`previousTopics` are the lessons *before* the period you name, each carrying
its own `periodId`, so a term is reconstructed by taking the distinct
`lessonId`s in a range, asking about the **latest** period of each, and
merging back by id — a few dozen calls for a school year. Asking about the
earliest period of a series reaches none of its history. `core/untis-history.ts`.
- **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.
@@ -383,7 +433,8 @@ cost the most:
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. A `untis_*` tool instead
registers only when `context.untis` exists.
registers only when `context.untis` exists, and a note tool only when
`config.notesDir` is set.
5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways.
## Resources and prompts
@@ -410,8 +461,9 @@ bundle (2.1.272), not its docs:
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
`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
four or none — a half-filled block is a paste that went wrong, so it throws),
`NOTES_DIR` and `UNTIS_HISTORY_DAYS`; docker-compose sets `STATE_DIR` and
`NOTES_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.