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

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 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.