The endurance test refuted the sliding-window model I committed earlier. A keepalive doing only GET /api/v3/me succeeded at t+0/30/60/90 and was still rejected by t+120 — consistent with the session ending ~2h after LOGIN (t+107), and inconsistent with 2h after the last request, which would have been t+210. This is a live-vs-source divergence, not a misreading: both the current JwtWhitelistAdapter and the legacy Feathers ensureTokenIsWhitelisted re-set the Valkey TTL on every authenticated request, so the source reads as a sliding window. The instance does not behave that way. So the keepalive now calls POST /authentication/refresh-session, the endpoint behind the UI's "Sitzung verlängern" button, which a separate 100s test showed does hold the reported budget at 7200s. It is the only non-GET request in the server: no body, touches only our own session, cannot read or modify user data, and is not exposed as a tool, so no model-driven call can ever be a POST. It logs the returned budget, which makes a failing extension visible before the session is lost. Whether this is sufficient is NOT established. Two mechanisms still fit: an idle TTL that reads fail to refresh (keepalive works), or an absolute cap/revocation anchored at login — e.g. the IDP's back-channel logout, which clears every token for the account rather than one. Added scripts/session-diagnose.mjs to settle it: it logs the budget every 10 min, so a decaying series indicates the former and an abrupt 401 at 7200s the latter. Docs state the open question rather than asserting a mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
6.4 KiB
Markdown
129 lines
6.4 KiB
Markdown
# CLAUDE.md
|
|
|
|
Guidance for Claude Code when working in this repository.
|
|
|
|
## What this is
|
|
|
|
An MCP server exposing a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software)
|
|
account to Claude, read-only: courses, column boards, lessons, tasks, and file
|
|
downloads with text extraction. TypeScript, Node 22+, `@modelcontextprotocol/sdk`.
|
|
|
|
Two entry points, one server definition:
|
|
- `src/bin/http.ts` — Streamable HTTP, the deployed form, behind Caddy on a Pi.
|
|
- `src/bin/stdio.ts` — stdio, for local Claude Code / Desktop use.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
npm run build # tsc → dist/
|
|
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 session-diagnose # ~2.5h: measure what actually ends the session
|
|
```
|
|
|
|
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
|
|
read-only. Run `smoke` after touching anything in `src/tools/` or
|
|
`src/schulcloud/` — the unit tests cover only pure functions.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
bin/{http,stdio}.ts → server.ts (createServer)
|
|
└─ tools/{overview,content,files,search,raw}.ts
|
|
└─ context.ts (caches /me → school id)
|
|
└─ schulcloud/client.ts (all GET, no writes)
|
|
schulcloud/board.ts (assembles boards)
|
|
extract.ts (documents → text)
|
|
render.ts (→ Markdown)
|
|
```
|
|
|
|
- **`schulcloud/client.ts`** — every upstream call. Methods are `GET`-only by
|
|
design; see "Invariants" below.
|
|
- **`schulcloud/board.ts`** — the non-obvious part. A column board needs three
|
|
kinds of call to reconstruct; this hides that.
|
|
- **`tools/*.ts`** — each registers a group of tools and formats results as
|
|
Markdown. Tool descriptions are prompts: they are how Claude decides which
|
|
tool to reach for, 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 required on every files-storage path and cannot change for a token.
|
|
|
|
## Invariants
|
|
|
|
**Everything that touches user data 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. 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.
|
|
- **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`.
|
|
- **The JWT dies ~2h after login, not after 30 days, and reads do not extend
|
|
it.** `exp` is only a hard ceiling. Both the current and legacy upstream
|
|
implementations re-set a Valkey TTL on every authenticated request, so the
|
|
source reads as if the window slides — measured against the live instance, it
|
|
does not: four successful `GET /me` pings at 30-min intervals did not prevent
|
|
a 401 by t+120. `src/keepalive.ts` therefore calls `refresh-session`. Do not
|
|
"simplify" it back to a GET, and do not trust the upstream source here.
|
|
Open question and the instrument to settle it: `docs/AUTH.md`,
|
|
`npm run session-diagnose`.
|
|
|
|
## 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
|
|
`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 `schulcloud/client.ts` (`GET` only).
|
|
2. Register the tool in the relevant `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. Add a check to `scripts/smoke.mjs` and run `npm run smoke`.
|
|
|
|
## 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.
|