# CLAUDE.md Guidance for Claude Code when working in this repository. ## What this is 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`. Three entry points over one core: - `src/bin/http.ts` — Streamable HTTP + `/api`, the deployed form, behind Caddy on a Pi. - `src/bin/stdio.ts` — stdio, for local Claude Code / Desktop use. - `src/bin/cli.ts` — the `schulcloud` CLI, which talks to the HTTP server, never to Schulcloud. ## Commands ```bash npm run build # tsc → dist/ (also copies store/migrations/*.sql) npm run dev # watch mode, runs src/ directly via type stripping npm test # unit tests (node:test), no network npm run typecheck npm run probe # verify token + API assumptions against the LIVE instance npm run smoke # full end-to-end: real server + real MCP client + real data npm run keepalive-status # is the deployed container holding its session? npm run session-diagnose # ~2.5h: measure what actually ends the session npm run publish-image # amd64 + arm64 image to registry.mc02.dev; clean tree only ``` `docker-compose.override.yml` is local-only and publishes the server on `127.0.0.1:8080` (or `MCP_HOST_PORT`) and Postgres on `127.0.0.1:55432`; see `docs/LOCAL.md`. Compose merges it whenever `COMPOSE_FILE` is unset, so the Pi's `.env` sets `COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml` instead; `docs/PI.md` is the setup guide. **The Pi never builds**: that file runs `registry.mc02.dev/schulcloud-mcp` and `!reset`s the build section, and `npm run publish-image` is how an image gets there — so a change reaches the Pi only once it is committed and published. When `.env` points at the live account, test against the local instance only through `local-instance/scripts/mcp-env.sh`: it pins its own database (`schulcloud_local`) and mirror, so fixtures cannot reach the live index. `probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are 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 (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: `TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on purpose — the generation/diff semantics are entirely SQL, so a mock would test nothing. **They `TRUNCATE`**, and refuse to run unless the database name contains "test"; that guard exists because pointing them at the dev database once put fixtures into real data. ## Architecture ``` bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*, mcp/{resources,prompts}.ts └─ http/{server,api,auth}.ts /mcp and /api │ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts 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. - `client.ts` — every upstream call; `GET`-only except `extendSession`. - `board.ts` — a column board needs three kinds of call to reconstruct. - **`crawl.ts`** — the one traversal. Search, the indexer, the what-changed diff and the file mirror all need it; keep it here, not in a tool. - `paths.ts` — the security boundary for mirrored filenames. See Invariants. - `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-, Geteilte Dateien) as one path tree, parsed from the legacy client's pages. A separate store from files-storage; the `fs_*` tools and `/api/fs` sit on it. - `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 headers (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to `/api/token`. docs/AUTH.md and docs/DEPLOYMENT.md say why each exists. - **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS. `Store.open` returns `undefined` when Postgres is down; callers degrade. - **`indexer/`** — crawl → persist → mirror bytes → extract text → index. Coalesces concurrent refreshes; enforces a minimum interval. The crawl walks topic-attached tasks too, which the course page does not list: without that they are unsearchable and their grades invisible. `INDEX_PERSONAL_FILES` additionally indexes personal files and submitted/returned work, including grade comments — that is what makes "what got graded this week" answerable, at roughly three extra requests per task. - **`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 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. ## Invariants **Everything that touches Schulcloud is read-only.** Every client method is a `GET` except `extendSession` (the keepalive's `refresh-session` call, which touches only our own session and is not exposed as a tool, so no model-driven call can be a POST). `api_get` rejects non-`/api/` paths and anything carrying a scheme or host. **Against the legacy client, GET-only is not enough:** `GET /files/share/` mints a share token and `GET /files/file?share=` grants a permission, so `getFileManagerPage` allows only the listing routes, by pattern — widen that pattern only with a route you have read the handler of. A pre-signed download URL is fetched with **no** credentials: it names another host, and neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POST /api/refresh` write only to the Pi's 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 `core/paths.ts`: `safeComponent` reduces one string to one safe component, and `resolveWithin` refuses anything that escapes the root. Do not bypass them with `path.join`, and keep the property that no `..` survives anywhere in a component — it is what makes the invariant checkable. The endpoint is internet-facing by necessity, so "a leaked token cannot act as the user" is 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; `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** — so nothing may log a URL path, and config errors describe the rule, not the value. **Live behaviour beats upstream source.** The clones in `vendor/` track `main` and may be ahead of what is deployed. When they disagree with the instance, the instance is right. `docs/API.md` records which is which. ## API gotchas These cost real time to discover; `docs/API.md` has the full list with evidence. - Course contents are at `GET /api/v3/course-rooms/{courseId}/board`. There is no `GET /api/v3/courses/{id}`, and `:roomId` there is the *course* id. - **Rooms ("Räume") are a separate space from courses, and the UI's naming is a trap**: the sidebar's *Kurse* entry links to `/rooms/courses-overview` and lists courses; *Räume* links to `/rooms` and lists rooms. A `/rooms/...` url says nothing about which. `list_rooms`/`get_room` cover the latter; a room holds boards only — no lessons, no tasks. Empty is normal and is also what a revoked membership looks like. - Room boards report `isVisible`, which the course-page projection does not, so a room's drafts can be named as drafts instead of being tried and 403ing. - `limit` is rejected above 100 though the spec says 99. Page at 99; the client clamps and `listAllCourses` pages for you. - There is no `GET /tasks/{id}`, and the task lists omit `description` — it only exists on the course page's task element. `get_task` does that join. - **`GET /cards?ids=` takes at most 20 ids** (the `qs` `arrayLimit` default), and fails above that with a validation error that blames the ids rather than their number. `MAX_IDS_PER_QUERY` in `core/client.ts`. Any board over 20 cards is affected, which is common. - **Never swallow a per-item crawl error.** Board failures used to be caught and dropped, so the index lost whole boards while the crawl reported success — which is how the 20-id limit went unnoticed. They go into `Snapshot.failures`. - **Record failures by kind, or transient ones become permanent.** Every failed file used to be marked done, so a timeout was never retried. A *download* failure is now recorded with `retry: true` and the next crawl tries again; an *extraction* failure is final. Two causes found on the first full crawl of a real account: downloads bounded by the 30 s request timeout (11 MB scans cut off mid-transfer — downloads now time out on 30 s of *silence*), and PDF text containing NUL, which Postgres `text` refuses — stripped in `recordFileText`. - **A full crawl can outlast one HTTP request.** The first one with the file manager took 14 minutes (every file downloaded once); Node's fetch abandons a response without headers after 5. `POST /api/refresh` takes `wait: false` and the CLI polls `/api/status`; `refresh_index` returns after 50 s and leaves the crawl running. Don't reintroduce a caller that waits on a full crawl inline. - **`GET /lessons/{id}/tasks` is a bare array whose items carry no id.** Not the `{data,total}` envelope, and `LessonLinkedTaskResponse` has no id field at all. A topic-attached task is thus unidentifiable from the API and invisible in both task lists once past due — 18 of 60 tasks on the real account. `core/lesson-page.ts` scrapes the ids off the legacy topic page. - **Collaborative text editor (Etherpad) contents are reachable, in two hops.** `GET /api/v3/collaborative-text-editor/content-element/{id}` returns the pad url *and* sets an Etherpad `sessionID` cookie; `/etherpad/p/{id}/export/txt` then returns the text. No Etherpad API key needed. `core/etherpad.ts` checks the url's host before sending the cookie to it. - **A draft board is listed on the course page but 403s when opened.** Say "not published yet", not "no access". - **File records are mutable**: `PATCH /file/rename/{id}` keeps the id and size, so the store's digest has to include the name. - **Submissions: only `GET /submissions/status/task/{taskId}` exists.** No list, no fetch-by-id, and the payload has no submitted text, grade comment or graded-at. Don't imply absent feedback means none was given. - **`/api/v1` is partly served, and it is production surface.** Exactly three legacy routes survive in the deployment's own ingress table (`dof_app_deploy/ansible/group_vars/all/x_ingress.yml`): **`/api/v1/courses`, `/api/v1/users`, `/api/v1/classes`**. Everything else under `/api/v1` is unrouted and 404s. They matter because v3 dropped things they still carry: `courses` has the description, `teacherIds`, `userIds` and `times` (the weekly timetable), and `users/{id}` is the **only** way to turn a user id into a name — submission `submitters`, file `creatorId` and course `teacherIds` are otherwise unreadable. Permission is per-account: a teacher may read their students, a student may read only themselves, so name resolution must degrade to "not visible to this account" rather than printing a bare id. - **The teacher's homework page is a different page from the student's.** Its tabs are `extended` and `submissions`, not `submission` and `feedback`, and the grade lives in the grading *form* (`name="grade"`, `name="gradeComment"`, one block per `submissionId`) rather than in rendered prose. The student parser finds nothing on it, which is why a teacher account reported every graded submission as "neither a percentage nor feedback was found" while the data was plainly there. `parseTeacherGrading` handles that side. - **A grade is a percentage (`Number` 0-100) or absent; there is no text grade.** Teachers commonly grade with `gradeComment` alone, so "graded by feedback" is a complete answer. `formatGradeState` in `mcp/tools/submissions.ts` owns that wording — don't reintroduce "no numeric grade recorded", which reads as a fault. - **Submitted text and grade comments are scraped, not fetched.** No API exposes them; the legacy page `GET /homework/{taskId}` renders them, and it authenticates by `jwt` **cookie**, not bearer. `core/homework-page.ts` parses it on `data-testid` hooks and every field is optional — a markup change must degrade to "not found", never break `get_task`. - **files-storage listing ignores the `parentType` path segment** — filter on each record's own `parentType`, or submission files get reported as grading files. - **Board file elements carry no file id.** Files are found by listing files-storage with `parentType: 'boardnodes'` and the *element* id as `parentId`. Same for `fileFolder` and `drawing`. - Files live in a separate service (`/api/v3/file/*`, repo `file-storage`) with its own OpenAPI document. It is not in the main `docs-json`. - Legacy lesson responses return ids as `{buffer:{data:[...]}}`; use `normalizeObjectId`. - **`updatedAt` on the course-board projection is the request time**, not a modification time — two reads seconds apart differ. Never build change detection on it; the store diffs crawl generations by identity instead. The dedicated endpoints (`/boards/{id}`, `/cards`, file records) are stable. - Many course PDFs are **image-only scans with no text layer** (3 of 4 sampled), so extraction legitimately yields nothing. `extract.ts` detects this and says so; do not "fix" it by retrying. `download_file` then falls back to `GET /file/preview/...`, which renders the page as a picture Claude can read — the answer for a scan, though it still leaves the file unsearchable. - **The preview endpoint has two enums, and both 400 without saying so.** `width` accepts only **50, 150 or 500** — a number outside that set is a validation error naming the value but not the permitted set. `outputFormat` accepts only **`image/webp`**; omitting it is worse than wrong, because the preview is then rendered in the *source* format and a PDF comes back as a PDF. The response also labels itself `webp` rather than `image/webp`, so the content type has to be normalised before anything will treat it as an image. - **A room's `allowedOperations` is an object, not a list.** Every operation is present with a boolean; `false` means denied. Typing it as `string[]` type-checks and throws `.some is not a function` the moment anything reads it. - **Schulcloud has no quiz of its own.** There is no quiz module or endpoint upstream: interactive exercises are H5P elements, whose `contentId` is the only handle onto the content, or external (LTI) tools behind `contextExternalToolId`. Say that rather than looking for a quiz API. - **The file manager is a third store, reachable only as HTML.** Persönliche, Kurs-, Team- and Geteilte Dateien live in the legacy `files` collection, not in files-storage: `list_files` answers 0 for a course holding dozens of worksheets, and 21 of 26 live courses keep material there. Its Feathers service is not in the ingress, so listings are parsed from `/files/{my,courses,teams,shared}` pages and downloads go through `GET /files/signedurl` (JSON). Folders are addressed by id alone — `/files/courses/{course}/{folder}` at any depth. `permittedDirectories` returns every course with **no** folders (it queries `refOwnerModel: 'courses'`, records say `'course'`), and `/files/search/` 504s, so walk listings. Course names contain `/`; resolve by joining segments. A listing page that does not parse must throw, never read as an empty folder — "0 files" is the bug this exists to fix. `docs/API.md` has the evidence. - **Teams cannot be read at any version.** v3 exposes only `GET /team/{teamId}/news`; upstream `main`'s teams controller is write-only (`POST :teamId/create-room`). `/teams` is the legacy client's HTML page, not an API. A team's *files* are reachable, through the file manager (`/teams/`). - **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at `GET /api/v3/config/public`) that every authenticated request re-sets. `src/keepalive.ts` holds it open — don't remove it. It cannot hold it across downtime: a host off for more than two hours loses the session (seen when a dev machine was off overnight), which is why the deployment is an always-on Pi and why a fresh token can be swapped in without a restart. - **A Schulportal tab left open revokes our token.** The `jwt` cookie *is* the browser's session token, same `jti`. The front end runs a client-side timer (reset only on route change, never from the server TTL) and calls `/logout?auto-logout=true` ~2h after login, which issues `POST /api/v3/logout` and deletes the shared key. No keepalive can prevent it; the fix is to close 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` emit `.js`. This lets `node --watch src/bin/http.ts` run the tree directly. - **No TypeScript parameter properties** (`constructor(private readonly x: T)`). Node's type stripping rejects them, which breaks `npm run dev` and `npm test`. Declare the field and assign it in the constructor body instead. - Tabs for indentation, single quotes, trailing commas. - Comments explain *why* — an API quirk, a security property, a trade-off — not what the line does. Several such comments encode findings that are expensive to rediscover; do not strip them. - Tool failures return `isError: true` with an actionable message via `mcp/tools/result.ts`. `toToolError` separates 401 (token expired — the user must act) from 403 (no access) from 404 (bad id) deliberately; keep that split. ## Adding a tool 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. 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 Claude Code is the client these are tested in, and it shapes them. Read from its bundle (2.1.272), not its docs: - **A prompt command's arguments are split on whitespace, and extra words are dropped** (`zipObject(argNames, input.split(/\s+/))`). A value of several words can only arrive joined, so `argumentText` turns `_` back into spaces and reads `-` as "skipped"; course names match by fragments (`resolveTarget`). Any new free-text argument needs the same treatment. - **The @ autocomplete fuzzy-matches `name` but displays `description`**, falling back to the name only when there is none — a description must carry the name. - **An @-mention resolves only URIs from `resources/list`**; a template alone cannot be mentioned. `McpServer` returns every template's listing in one reply and ignores cursors, which suits a few dozen entries and not the file manager. - **Throw `ProtocolError`, not `McpError`**: McpError prefixes its message with "MCP error :", the client prefixes it again, and people read both. - A resource listing that fails yields no entries rather than an error: one refused kind would otherwise cost the whole reply. ## Environment `.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 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.