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:
70
CLAUDE.md
70
CLAUDE.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user