Initial schulcloud-mcp server

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>
This commit is contained in:
2026-09-11 23:52:12 +02:00
commit 35125b7683
38 changed files with 6317 additions and 0 deletions

112
CLAUDE.md Normal file
View File

@@ -0,0 +1,112 @@
# 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
```
`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 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
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`.
## Conventions
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
emit `.js`. This lets `node --watch src/bin/http.ts` run 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: 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 — it expires every 30
days, and `npm run probe` reports the days remaining.