Read-only MCP server exposing a Schulcloud account to Claude: courses,
column boards, lessons, tasks, and file downloads with text extraction.
The API surface was verified against the live instance rather than
inferred from upstream source, which changed several design decisions:
- The `jwt` cookie works verbatim as `Authorization: Bearer` and lasts 30
days, so there is no cookie jar and no refresh-session timer.
- Course contents live at /api/v3/course-rooms/{courseId}/board; there is
no GET /api/v3/courses/{id}.
- Files are a separate service (/api/v3/file/*) with its own OpenAPI doc.
- Board file elements carry no file id; attachments are resolved by
listing files-storage with parentType=boardnodes and the element id.
Read-only by construction: every client method is a GET, including the
api_get escape hatch. The endpoint is internet-facing by necessity, so a
leaked token being unable to act as the user is the key safety property.
Deploys as a container behind the Pi's existing Caddy, guarded by a
constant-time bearer check. Stateless — no database.
Verified: 28 unit tests, plus a 30-check end-to-end run driving a real
MCP client over Streamable HTTP against the live account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.3 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
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 is read-only. Every client method is a GET, and 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 for 30 days; 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.
Conventions
- Imports use
.tsextensions;rewriteRelativeImportExtensionsmakestscemit.js. This letsnode --watch src/bin/http.tsrun the tree directly. - 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 — it expires every 30
days, and npm run probe reports the days remaining.