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

135
docs/API.md Normal file
View File

@@ -0,0 +1,135 @@
# The Schulcloud API, as verified against this instance
Everything here was confirmed against `https://schulcloud-thueringen.de` with a
real student account on 2026-09-11, not inferred from source. Where upstream
source and live behaviour disagreed, live behaviour won.
## Two services, one origin
| Service | Source repo | Base path | Self-documenting at |
|---|---|---|---|
| Main server (NestJS) | [`schulcloud-server`](https://github.com/hpi-schul-cloud/schulcloud-server) | `/api/v3/` | `/api/v3/docs`, `/api/v3/docs-json` |
| Files storage | [`file-storage`](https://github.com/hpi-schul-cloud/file-storage) | `/api/v3/file/` | `/api/v3/file/docs`, `/api/v3/file/docs-json` |
Both accept the same bearer token. The files service was split out of
`schulcloud-server` into its own repository, which is why no `file` paths
appear in the main `docs-json` — a detail that will send you in circles if you
only read the main spec. Fetch both:
```bash
curl -s "$TSC_URL/api/v3/docs-json" -o docs-v3.json # 212 paths
curl -s "$TSC_URL/api/v3/file/docs-json" -o docs-file.json # 26 paths
```
These are the authoritative reference for *this* instance's deployed version.
Prefer them over the GitHub sources, which track `main` and may be ahead.
## Which repositories matter
The `hpi-schul-cloud` org has ~100 repos, most archived or superseded. The live
ones relevant here:
- **`schulcloud-server`** — the API. Read `apps/server/src/modules/<module>/api/`
for controllers and DTOs.
- **`file-storage`** — the files service, extracted from the above.
`src/modules/files-storage/api/controller/files-storage.controller.ts` is the
whole surface.
- **`nuxt-client`** — the current web front end. Useful for seeing which API
calls the real UI makes in which order.
- **`schulcloud-client`** — the *legacy* Handlebars front end. Still receives
commits, but it is not where new features land.
Superseded/archived and worth ignoring: `authorization-service`,
`schulcloud-editor`, `nexboard-api-js`, `end-to-end-tests`, `docker-compose`,
`H5P-Nodejs-library`, `shd-client`.
Note the naming: `/api/v1` is the old Feathers surface. On this instance
`/api/v1/docs` 404s, and the v3 NestJS API covers everything this server needs.
## Content model
```
Course ─┬─ column board ─── column ─── card ─── element ─┬─ richText
│ ├─ file ──── fileRecord(s)
│ ├─ link
│ └─ …
├─ lesson (Thema) ─── contents[] + materials[]
└─ task (Aufgabe) ─── description + fileRecord(s)
```
On the account this was built against: 26 courses holding 30 column boards, 18
lessons, 42 tasks and 175 files. **Column boards hold the great majority of
current material**; lessons are the older format.
## Endpoints this server uses
| Purpose | Call |
|---|---|
| Identity, school id, permissions | `GET /api/v3/me` |
| Courses | `GET /api/v3/courses?skip&limit` |
| One course's contents | `GET /api/v3/course-rooms/{courseId}/board` |
| Dashboard tiles | `GET /api/v3/dashboard` |
| Tasks | `GET /api/v3/tasks`, `GET /api/v3/tasks/finished` |
| Lesson body | `GET /api/v3/lessons/{lessonId}` |
| Lesson's tasks | `GET /api/v3/lessons/{lessonId}/tasks` |
| Board structure | `GET /api/v3/boards/{boardId}` |
| What a board belongs to | `GET /api/v3/boards/{boardId}/context` |
| Card bodies | `GET /api/v3/cards?ids=<id>&ids=<id>` |
| Files of an entity | `GET /api/v3/file/list/{storageLocation}/{storageLocationId}/{parentType}/{parentId}` |
| One file's metadata | `GET /api/v3/file/{fileRecordId}` |
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
| News | `GET /api/v3/news` |
### Gotchas that cost real time
**`course-rooms`, not `courses`, for course contents.** `GET /api/v3/courses/{id}`
does not exist. The route that returns a course's lessons/tasks/boards is
`GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id.
Nothing in the naming suggests this.
**`/api/v3/rooms` is a different feature.** "Rooms" are the newer standalone
collaboration spaces, unrelated to courses. On this instance the account has
none, so `GET /api/v3/rooms` returns `{"data":[]}` — which reads like a broken
endpoint but is simply an empty feature.
**`limit` maxima are enforced and mis-documented.** The OpenAPI schema says
`maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to
satisfy both. Asking for 200 returns a `400 API_VALIDATION_ERROR`, not a
truncated list.
**There is no `GET /tasks/{id}`.** Single-task detail has to be assembled: the
list endpoints give metadata but *omit `description`*, which appears only on the
course page's task element. `get_task` does this join.
**Board files need three calls.** A `file` element's `content` carries only
`{caption, alternativeText}` — no file id. The bytes are found by listing
files-storage with `parentType: 'boardnodes'` and the **element** id as
`parentId`. This is the single least discoverable part of the API, and applies
equally to `fileFolder` and `drawing` elements.
**`storageLocationId` is the school id** (from `/me`), with
`storageLocation: 'school'`, for every parent type in normal use.
**Lesson ids come back as buffers.** `GET /api/v3/lessons/{id}` returns nested
ids as `{buffer:{type:'Buffer',data:[...]}}` rather than hex strings — a leak
from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts`
converts them.
**`Content-Disposition` on downloads is malformed.** It comes back as
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
percent-encoded inside the quotes. Parse defensively.
### Content element types
From `ContentElementType` in `schulcloud-server`, all seen live except where
noted: `richText`, `file`, `fileFolder`, `link`, `drawing`,
`collaborativeTextEditor`, `externalTool`, `videoConference`, `h5p`, `deleted`.
Collaborative text editor contents are **not** retrievable through the API —
`GET /api/v3/collaborative-text-editor/{parentType}/{parentId}` returns a URL to
the Etherpad-style editor, not the document text.
## Re-verifying after an upstream release
`npm run probe` re-checks every assumption above against the live instance and
prints what it finds, including days left on the token.

93
docs/AUTH.md Normal file
View File

@@ -0,0 +1,93 @@
# Authentication
## What this server uses
The `jwt` cookie from a logged-in browser session, sent verbatim as
`Authorization: Bearer <token>`. That is the whole mechanism.
This was worth confirming rather than assuming, because the obvious reading of
"it's a cookie" leads somewhere much more complicated. Verified live:
```
Authorization: Bearer <jwt> → 200 # what this server does
Cookie: jwt=<jwt> → 200 # also works
(no auth) → 401
```
`connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed. There is no
cookie jar, no session to keep alive, and no `refresh-session` call on a timer.
## Token lifetime: 30 days
The token is a standard JWT. Decoded from the live instance:
```
iss / aud : schulcloud-thueringen.de
iat → exp : 720 hours (exactly 30 days)
claims : accountId, userId, schoolId, roles, systemId, jti,
isExternalUser, isServiceAccount, support
```
So a token copied today works for a month, and refreshing it is a calendar
chore rather than an engineering problem. `npm run probe` prints the days
remaining.
## Getting a fresh token
1. Log in to the instance in a normal browser.
2. DevTools → **Application****Cookies** → the instance's origin.
3. Copy the value of the **`jwt`** cookie.
4. Put it in `TSC_JWT_COOKIE` in `.env` and restart the server
(`docker compose restart schulcloud-mcp`).
There is no need to log out afterwards; the token stays valid independently of
the browser session.
## How you will know it expired
Every tool returns a specific message on `401` rather than a generic failure:
> Schulcloud rejected the token … The JWT in TSC_JWT_COOKIE has expired or been
> revoked.
That message is the signal to redo the four steps above. A `403` means the
account genuinely lacks access to that resource and is *not* a token problem.
## Why not username + password
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri`
pointing back at Schulcloud's own server, so the authorization-code exchange
happens server-side with a client secret only Schulcloud holds. A third party
cannot replicate that flow. `POST /api/v3/authentication/local` exists but is
for accounts with local credentials, which federated school accounts do not
have.
Given a 30-day token, the pasted-JWT approach is the right trade: one manual
step a month against re-implementing an OAuth client we cannot hold the secret
for. If this ever needs to be unattended, the honest options are a service
account issued by the school's IDM, or a headless browser login — not a
reimplementation of the Keycloak exchange.
## Protecting this server's own endpoint
Distinct from the above, and just as important. The MCP endpoint is reachable
from the public internet by construction: Claude's connectors call it from
Anthropic's cloud, not from your machine. It is protected by `MCP_AUTH_TOKEN`,
a shared secret checked in constant time on every `/mcp` request
(`src/http/auth.ts`), accepted as either `Authorization: Bearer …` or
`X-Api-Key`. `/healthz` is deliberately open and reveals nothing.
Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud
warning and serves unauthenticated — only acceptable bound to localhost.
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, restart the container, update
the connector in Claude. Nothing else stores it.
## Blast radius
Every path in this server is a `GET`, including the `api_get` escape hatch,
which rejects anything not starting with `/api/` and anything carrying a scheme
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` could
read this account's Schulcloud data; they could not post, submit, delete, or
otherwise act as the user. Keep it that way — adding a single write tool would
change that property entirely.

157
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,157 @@
# Deployment
## The shape of it
```
claude.ai ──HTTPS──▶ VPS (public IP) ──tunnel──▶ Pi 5 (home network)
└─ Caddy ──▶ schulcloud-mcp:8080
└──▶ schulcloud-thueringen.de
```
Claude's custom connectors call the endpoint from Anthropic's cloud, so it must
be publicly reachable over real TLS — a localhost tunnel or self-signed cert
will not do. The VPS provides the public address; Caddy on the Pi terminates
TLS and obtains the certificate.
The container publishes no host port. Caddy reaches it over the shared Docker
network, so the only way in from the internet is through Caddy and then through
this server's bearer check.
## First deploy
```bash
git clone <this repo> /opt/schulcloud-mcp
cd /opt/schulcloud-mcp
cp .env.example .env
# Fill in TSC_URL and TSC_JWT_COOKIE (see docs/AUTH.md), then:
openssl rand -hex 32 # → MCP_AUTH_TOKEN
docker compose up -d --build
docker compose logs -f schulcloud-mcp
```
Expect:
```
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled
```
`auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing
the service.
## Joining the existing Caddy
The Pi already runs Caddy and PostgreSQL in a Compose project. This server needs
neither a database nor its own Caddy — only a network it shares with the
existing one.
Find the network Caddy is on:
```bash
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' <caddy-container>
```
Then in `docker-compose.yml`, set that name and mark it external:
```yaml
networks:
caddy:
external: true
name: <the network name you just found>
```
Append `deploy/Caddyfile.snippet` to the Pi's Caddyfile, replacing
`mcp.example.org` with the real hostname, and reload:
```bash
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
```
Two settings in that snippet matter and are easy to miss:
- **`flush_interval -1`** — MCP's Streamable HTTP transport holds a
server-sent-events channel open. Without this, Caddy buffers it and the
connector hangs with no error.
- **`read_timeout`/`write_timeout` of 300s** — a `search` call walks every
course and can take tens of seconds. Caddy's defaults will cut it off.
## Ports and DNS
- DNS for the hostname points at the **VPS**, not the Pi.
- The VPS forwards 80 and 443 to the Pi's Caddy. Port 80 must work too, or
Caddy cannot complete the ACME HTTP challenge.
- Nothing else needs to be exposed.
## Verifying from outside
```bash
curl -s https://mcp.example.org/healthz
# {"status":"ok","sessions":0}
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \
-H 'content-type: application/json' -d '{}'
# 401 ← the bearer check is live
```
If `/healthz` answers but `/mcp` returns 401 with a correct token, check that
the token in `.env` matches the one in the connector exactly — no trailing
newline from a copy-paste.
## Connecting Claude
1. claude.ai → **Settings → Connectors → Add custom connector**.
2. URL: `https://mcp.example.org/mcp`
3. Under **Advanced settings**, add the bearer token as an authorization
header. If your organisation has no header-auth field, the server also
accepts the token as `X-Api-Key`.
4. Enable the connector in a conversation via **+ → Add connectors**.
Ask *"which courses am I in?"* as a first check — that exercises auth, the
Schulcloud token and the API in one call.
## Running it locally instead
For Claude Code or Claude Desktop on your own machine, skip all of the above and
use stdio:
```json
{
"mcpServers": {
"schulcloud": {
"command": "node",
"args": ["/path/to/schulcloud-mcp/dist/bin/stdio.js"],
"env": {
"TSC_URL": "https://schulcloud-thueringen.de",
"TSC_JWT_COOKIE": "…"
}
}
}
}
```
`MCP_AUTH_TOKEN` is irrelevant in stdio mode — there is no network listener.
## Updating
```bash
cd /opt/schulcloud-mcp && git pull
docker compose up -d --build
docker compose exec schulcloud-mcp node -e "1" # sanity
npm run probe # re-verify the API assumptions
```
## Operational notes
- **Restart policy** is `unless-stopped`; the container comes back after a
reboot.
- **Sessions** are in-memory and dropped after 30 minutes idle. A restart
invalidates them; Claude re-initializes transparently.
- **Logs** are capped at 3 × 10 MB. The Authorization header is never logged.
- **The container is read-only** with `cap_drop: ALL` and
`no-new-privileges`, running as the unprivileged `node` user. It writes
nothing to disk — downloads are streamed through memory, capped at
`MAX_DOWNLOAD_BYTES` (25 MiB default).
- **Monthly chore**: refresh `TSC_JWT_COOKIE`. `npm run probe` tells you how
many days are left.