Files
Schulcloud-MCP/CLAUDE.md
MechaCat02 60ca4d3eba Keepalive via refresh-session; GET pings measured insufficient
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>
2026-09-12 15:46:54 +02:00

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