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>
6.4 KiB
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
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 areGET-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/meis 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 noGET /api/v3/courses/{id}, and:roomIdthere is the course id. /api/v3/roomsis an unrelated newer feature, not courses. Empty is normal.limitis rejected above 100 though the spec says 99. Page at 99; the client clamps andlistAllCoursespages for you.- There is no
GET /tasks/{id}, and the task lists omitdescription— it only exists on the course page's task element.get_taskdoes that join. - Board file elements carry no file id. Files are found by listing
files-storage with
parentType: 'boardnodes'and the element id asparentId. Same forfileFolderanddrawing. - Files live in a separate service (
/api/v3/file/*, repofile-storage) with its own OpenAPI document. It is not in the maindocs-json. - Legacy lesson responses return ids as
{buffer:{data:[...]}}; usenormalizeObjectId. - The JWT dies ~2h after login, not after 30 days, and reads do not extend
it.
expis 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 successfulGET /mepings at 30-min intervals did not prevent a 401 by t+120.src/keepalive.tstherefore callsrefresh-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
.tsextensions;rewriteRelativeImportExtensionsmakestscemit.js. This letsnode --watch src/bin/http.tsrun the tree directly. - No TypeScript parameter properties (
constructor(private readonly x: T)). Node's type stripping rejects them, which breaksnpm run devandnpm 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: truewith an actionable message viatools/result.ts.toToolErrorseparates 401 (token expired — the user must act) from 403 (no access) from 404 (bad id) deliberately; keep that split.
Adding a tool
- Add the client method in
schulcloud/client.ts(GETonly). - Register the tool in the relevant
tools/*.ts, with a description that says when to use it and when not to. - Format output as Markdown, keeping ids visible for follow-up calls.
- Add a check to
scripts/smoke.mjsand runnpm 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.