Files
Schulcloud-MCP/CLAUDE.md
MechaCat02 9ce869f3fb Root cause: an open Schulportal tab revokes the shared token
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>
2026-09-12 16:32:42 +02:00

131 lines
6.6 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`.
- **`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
`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.