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:
135
docs/API.md
Normal file
135
docs/API.md
Normal 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
93
docs/AUTH.md
Normal 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
157
docs/DEPLOYMENT.md
Normal 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.
|
||||
Reference in New Issue
Block a user