Neither of my two hypotheses was right, and the upstream source was
correct all along. The jwt cookie copied from the browser IS the
browser's session token — same jti — so this server and the tab share
one session, and the tab ends it:
1. nuxt-client sets a purely client-side timer, sessionTimeoutTimestamp
= now + JWT_TIMEOUT_SECONDS, reset only on route change
(watch(router.currentRoute, startTimer)) — never by API activity and
never read back from the server's TTL.
2. AutoLogoutWarning.vue warns at JWT_SHOW_TIMEOUT_WARNING_SECONDS.
3. At zero, autoLogout() -> location.replace('/logout?auto-logout=true').
4. schulcloud-client controllers/login.js:439 -> POST /api/v3/logout
-> removeJwtFromWhitelist(jwt) -> the shared key is deleted.
That explains the endurance failure exactly: the GET pings at t+0/30/60/90
were sliding the Valkey TTL correctly, and then the tab deleted the key.
It also explains the ~1h warning dialog appearing in a tab the user
considers in use — the timer only resets on navigation.
So the sliding TTL is real and a keepalive does hold a session to the
30-day ceiling. The operational fix is not to ping harder but to close
the Schulportal window after copying the cookie; a private window is the
tidy way. This is now the loudest caveat in the token-copying steps,
because it is the single easiest way to break the setup.
Keeping refresh-session rather than reverting to GET, now for a reason
that stands on its own: it states the intent contractually instead of
relying on extend-on-check as a side effect of an unrelated read (that
whitelist has been refactored twice in 2026, and a GET keepalive would
fail silently if it went away), and its budget readout makes session
health visible in the log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.6 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. exp(30 days) is not the session lifetime. The binding limit is a Valkey whitelist entry with aJWT_TIMEOUT_SECONDSTTL (7200s; live value atGET /api/v3/config/public) that every authenticated request re-sets.src/keepalive.tsholds it open — don't remove it.- A Schulportal tab left open revokes our token. The
jwtcookie is the browser's session token, samejti. 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 issuesPOST /api/v3/logoutand 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.mdhas the chain.
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.