# 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. 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 ``` `docker-compose.override.yml` is local-only and publishes the server on `127.0.0.1:8080` and Postgres on `127.0.0.1:55432`; see `docs/LOCAL.md`. `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 (34 checks, index-backed) and without (32 checks, live-only). The degradation path is a supported mode, not a fallback nobody exercises. 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/* └─ http/{server,api,auth}.ts /mcp and /api │ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts services.ts (process-wide: client, Store, Indexer) │ indexer/indexer.ts ── store/store.ts ── Postgres │ core/{client,board,crawl,extract,text,paths,types} ``` - **`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. - **`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. - **`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. - **`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. `refresh_index` and `POST /api/refresh` write only to the Pi's own index and mirror — every upstream call they make is still a GET. **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` guards the endpoint. Neither belongs in logs, error messages, or tool output. `.env` is git-ignored — keep it that way. **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. - `/api/v3/rooms` is an unrelated newer feature, not courses. Empty is normal. - `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`. - **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 — `/api/v1`, which had them, is not served here. Don't imply absent feedback means none was given. - **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. - **`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. - **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. ## 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). 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. 5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways. ## Environment `.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example` for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe` reports both clocks: days until hard expiry and seconds of idle budget left.