docs/API.md gains the findings: the endpoint and its required version parameter, the one-time code and the two error codes worth naming, the Z that means local time, a substitution being two periods, the unused exam module that puts announced tests in the period notes, and a day without lessons that is not a holiday. Those cost an afternoon of probing to learn and nothing upstream states them. docs/AUTH.md sets the key against the Schulcloud token it sits beside: no password, nothing to keep alive, revocable on its own, and not read-only in itself — which is why the allowlist exists. PI.md and DEPLOYMENT.md add the four values, the container recreate a changed key needs, and the clock requirement; the Pi's troubleshooting table gains both failure messages. README and CLAUDE.md say what the server now is: Schulcloud for the material, WebUntis for the day. Smoke is 89 checks with the index and a key, 87 live-only, 9 fewer without one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
454 lines
19 KiB
Markdown
454 lines
19 KiB
Markdown
# Setting up on the Raspberry Pi
|
|
|
|
A step-by-step guide from a Pi with Docker to a working claude.ai connector.
|
|
[DEPLOYMENT.md](DEPLOYMENT.md) explains *why* each piece is there; this page is
|
|
the order to do it in.
|
|
|
|
The Pi never builds the server. It pulls the image published to
|
|
`registry.mc02.dev`, built for arm64 and amd64 by `npm run publish-image` on a
|
|
development machine ([DEPLOYMENT.md](DEPLOYMENT.md#publishing-an-image)).
|
|
|
|
```
|
|
claude.ai ─┐ ┌──────────── Pi (home, always on) ─────────────┐
|
|
Claude Code├─HTTPS─▶ VPS ─TCP─▶ │ Caddy ─▶ schulcloud-mcp ─▶ schulcloud-thueringen.de
|
|
CLI ───────┘ (public IP, │ (TLS) └─ postgres (private network) │
|
|
your domain) └────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## What you need
|
|
|
|
- **The Pi**, always on. More than two hours offline ends the Schulcloud
|
|
session, and only a new token from a browser revives it. A Pi 5 on 64-bit Raspberry Pi
|
|
OS is plenty; put Docker's data on an SSD rather than the SD card if you can,
|
|
since Postgres and the file mirror (about 1.3 GB on the current account) write
|
|
to it.
|
|
- **Docker Engine with the Compose plugin**, Compose 2.24 or later, on the Pi.
|
|
- **A login for `registry.mc02.dev`**, where the image is published.
|
|
- **Caddy running in a container on the Pi**, serving ports 80 and 443. This
|
|
server joins its Docker network; it opens no port of its own.
|
|
- **The VPS**, with a public IPv4 address, forwarding TCP ports 80 and 443 to the
|
|
Pi (step 7).
|
|
- **A hostname** in your domain — `mcp.example.org` below — whose `A` record
|
|
points at the VPS.
|
|
- **Your Schulcloud login**, for the first token, and a machine with the
|
|
`schulcloud` CLI (`npm link` in a checkout; see [CLI.md](CLI.md)).
|
|
|
|
## 1. Prepare the Pi
|
|
|
|
```bash
|
|
sudo apt update && sudo apt full-upgrade -y
|
|
curl -fsSL https://get.docker.com | sh
|
|
sudo usermod -aG docker "$USER" # then log out and back in
|
|
docker compose version # v2.24 or later
|
|
timedatectl # "System clock synchronized: yes"
|
|
docker login registry.mc02.dev
|
|
```
|
|
|
|
The clock matters: token expiry and TLS certificates are both judged by it. The
|
|
login is needed to pull, not only to push; Docker keeps it in
|
|
`~/.docker/config.json`.
|
|
|
|
## 2. Get the compose files
|
|
|
|
```bash
|
|
sudo mkdir -p /opt/schulcloud-mcp && sudo chown "$USER": /opt/schulcloud-mcp
|
|
git clone https://git.mc02.dev/fabi/Schulcloud-MCP.git /opt/schulcloud-mcp
|
|
cd /opt/schulcloud-mcp
|
|
```
|
|
|
|
Every command below runs in `/opt/schulcloud-mcp`. The checkout provides the
|
|
compose files, `.env.example` and the Caddy snippet; its source code is not built
|
|
here.
|
|
|
|
## 3. Configure
|
|
|
|
Find the Docker network your Caddy container is on:
|
|
|
|
```bash
|
|
docker ps --format '{{.Names}}' | grep -i caddy
|
|
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' <caddy-container>
|
|
```
|
|
|
|
Create `.env`, readable by you alone, and add the Pi's settings with freshly
|
|
generated secrets — replace `<network>` with the name you just found:
|
|
|
|
```bash
|
|
cp .env.example .env && chmod 600 .env
|
|
sed -i '/^MCP_AUTH_TOKEN=$/d; /^DATABASE_URL=/d' .env
|
|
cat >> .env <<EOF
|
|
|
|
# --- the Pi ---
|
|
COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml
|
|
CADDY_NETWORK=<network>
|
|
POSTGRES_PASSWORD=$(openssl rand -hex 24)
|
|
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
|
|
MCP_CONNECTOR_TOKEN=$(openssl rand -hex 32)
|
|
INDEX_PERSONAL_FILES=true
|
|
# SCHULCLOUD_MCP_TAG=latest
|
|
EOF
|
|
```
|
|
|
|
If your school publishes its timetable in **WebUntis**, add those four values
|
|
too — the timetable, its cancellations and substitutions are not in Schulcloud
|
|
at all. They are in WebUntis under Profil → Freigaben → Untis Mobile → QR-Code:
|
|
|
|
```bash
|
|
cat >> .env <<'EOF'
|
|
UNTIS_SERVER=yourschool.webuntis.com
|
|
UNTIS_SCHOOL=yourschool
|
|
UNTIS_USER=your.username
|
|
UNTIS_SECRET=THEKEYFROMTHEQRDIALOG
|
|
EOF
|
|
```
|
|
|
|
What those lines do:
|
|
|
|
| Setting | |
|
|
|---|---|
|
|
| `COMPOSE_FILE` | Makes every `docker compose` command here use [`deploy/docker-compose.pi.yml`](../deploy/docker-compose.pi.yml), which runs the registry image — removing the build section, so nothing can be built here — joins Caddy's network and wires up the database. It also keeps `docker-compose.override.yml` out — that one is for local development and would publish ports and switch the crawl timer off. |
|
|
| `CADDY_NETWORK` | The network Caddy reaches this server on, by the name `schulcloud-mcp`. |
|
|
| `POSTGRES_PASSWORD` | The bundled Postgres, which sits on a private network with this server only. Hex, so it needs no escaping inside the connection URL. |
|
|
| `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. |
|
|
| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
|
|
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
|
|
| `UNTIS_*` | WebUntis, where the school keeps the timetable. All four or none; the key needs no password and does not expire. See [AUTH.md](AUTH.md). Optional. |
|
|
| `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. |
|
|
|
|
**Copy `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` into your password manager
|
|
now** — you need both again in step 9, and neither is ever printed by the server:
|
|
|
|
```bash
|
|
grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN)=' .env
|
|
```
|
|
|
|
## 4. The first Schulcloud token
|
|
|
|
1. Open a **private window** and log in to Schulcloud.
|
|
2. DevTools (F12) → *Application* (Firefox: *Storage*) → *Cookies* → the cookie
|
|
named `jwt` → copy its value.
|
|
3. Put it into `.env` as `TSC_JWT_COOKIE=<value>`.
|
|
4. **Close the private window.** Left open, it logs the token out about two hours
|
|
after login ([AUTH.md](AUTH.md) has the whole story).
|
|
|
|
This is the only time the token goes into `.env`. Later ones go in without a
|
|
restart (step 11).
|
|
|
|
## 5. Start the server
|
|
|
|
```bash
|
|
docker compose pull # the published image, and Postgres
|
|
docker compose up -d
|
|
docker compose ps
|
|
docker compose logs -f schulcloud-mcp
|
|
```
|
|
|
|
Nothing is built on the Pi: `pull` fetches the arm64 variant of the image, and
|
|
with the Pi file in place no `docker compose` command can fall back to a build.
|
|
|
|
Expect, within a few seconds:
|
|
|
|
```
|
|
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus connector token), token from environment, 29 day(s) left, keepalive every 30min, index every 6h
|
|
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
|
|
```
|
|
|
|
- `auth DISABLED` means `MCP_AUTH_TOKEN` is empty. Stop and fix it.
|
|
- `keepalive: token rejected (401)` means the token is already dead: get a fresh
|
|
one (step 4) and hand it over with `schulcloud token set` once step 9 is done.
|
|
|
|
The index is empty, so the first full crawl starts on its own. It downloads every
|
|
course file once — about 15 minutes and 1.3 GB on the current account — and the
|
|
server answers normally meanwhile.
|
|
|
|
`docker compose ps` must show **no published ports** for either container.
|
|
|
|
## 6. Add the site to Caddy
|
|
|
|
Append [`deploy/Caddyfile.snippet`](../deploy/Caddyfile.snippet) to the Pi's
|
|
Caddyfile, with your hostname in place of `mcp.example.org`, then validate and
|
|
reload:
|
|
|
|
```bash
|
|
docker exec <caddy-container> caddy validate --config /etc/caddy/Caddyfile
|
|
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
|
|
```
|
|
|
|
Keep the snippet's three easily-missed settings:
|
|
|
|
- `flush_interval -1`, or claude.ai's connection hangs without an error.
|
|
- The long timeouts, or a slow `search` is cut off.
|
|
- The `format filter` in `log`, which keeps a secret path out of the access log if
|
|
you ever use one (step 9).
|
|
|
|
Caddy gets its certificate once DNS and the forwarding work (step 7). Watch for
|
|
it with `docker logs -f <caddy-container> | grep -i certificate`.
|
|
|
|
## 7. DNS and the VPS
|
|
|
|
**DNS:** an `A` record for `mcp.example.org` pointing at the VPS's public IPv4.
|
|
Publish no `AAAA` record unless the VPS forwards IPv6 as well.
|
|
|
|
**The forwarding must pass TCP through, untouched.** TLS has to end at Caddy on
|
|
the Pi. A VPS that terminates TLS itself, or proxies HTTP, sees every request —
|
|
the claude.ai token in its header included — and may log it. Both ports are
|
|
needed: 80 for the certificate challenge, 443 for everything else.
|
|
|
|
If the VPS already forwards to the Pi, check how. On the VPS:
|
|
|
|
```bash
|
|
sudo ss -ltnp '( sport = :443 )'
|
|
```
|
|
|
|
No listening process is the good answer: the kernel forwards the packets, as in
|
|
the example below. An nginx `stream` block or HAProxy in `mode tcp` is also fine.
|
|
An nginx `http` server, a Caddy, or HAProxy in `mode http` on the VPS is not.
|
|
|
|
### Example: WireGuard and nftables
|
|
|
|
Skip this if your forwarding already passes TCP through. Otherwise, a minimal
|
|
tunnel with the VPS as `10.8.0.1` and the Pi as `10.8.0.2`. Create keys on each
|
|
machine with `umask 077; wg genkey | tee private.key | wg pubkey > public.key`.
|
|
|
|
On the VPS, `/etc/wireguard/wg0.conf`:
|
|
|
|
```ini
|
|
[Interface]
|
|
Address = 10.8.0.1/24
|
|
ListenPort = 51820
|
|
PrivateKey = <vps private key>
|
|
|
|
[Peer]
|
|
PublicKey = <pi public key>
|
|
AllowedIPs = 10.8.0.2/32
|
|
```
|
|
|
|
On the Pi, `/etc/wireguard/wg0.conf`:
|
|
|
|
```ini
|
|
[Interface]
|
|
Address = 10.8.0.2/24
|
|
PrivateKey = <pi private key>
|
|
|
|
[Peer]
|
|
PublicKey = <vps public key>
|
|
Endpoint = <vps public ip>:51820
|
|
AllowedIPs = 10.8.0.1/32
|
|
PersistentKeepalive = 25
|
|
```
|
|
|
|
Bring the tunnel up on both (`sudo apt install wireguard` first), and keep it up
|
|
across reboots:
|
|
|
|
```bash
|
|
sudo systemctl enable --now wg-quick@wg0
|
|
ping -c 3 10.8.0.1 # from the Pi
|
|
```
|
|
|
|
On the VPS, turn on forwarding and send ports 80 and 443 into the tunnel.
|
|
Replace `eth0` with the VPS's public interface (`ip route get 1.1.1.1` names it):
|
|
|
|
```bash
|
|
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-forward.conf
|
|
sudo sysctl --system
|
|
```
|
|
|
|
```nft
|
|
# /etc/nftables.conf on the VPS (merge into what is there)
|
|
table ip schulcloud_forward {
|
|
chain prerouting {
|
|
type nat hook prerouting priority dstnat; policy accept;
|
|
iifname "eth0" tcp dport { 80, 443 } dnat to 10.8.0.2
|
|
}
|
|
chain postrouting {
|
|
type nat hook postrouting priority srcnat; policy accept;
|
|
oifname "wg0" ip daddr 10.8.0.2 tcp dport { 80, 443 } masquerade
|
|
}
|
|
}
|
|
```
|
|
|
|
```bash
|
|
sudo nft -c -f /etc/nftables.conf && sudo systemctl enable --now nftables
|
|
```
|
|
|
|
If the VPS's `forward` chain has `policy drop`, also accept `ct state
|
|
established,related` and new TCP 80/443 from `eth0` to `wg0`. Its firewall
|
|
needs 80/tcp, 443/tcp and 51820/udp open, plus SSH. With the masquerade, Caddy's
|
|
logs show the VPS's tunnel address as the client; nothing here relies on client
|
|
addresses.
|
|
|
|
Check from the VPS that the Pi's Caddy answers through the tunnel:
|
|
|
|
```bash
|
|
curl -sI http://10.8.0.2/ -H 'Host: mcp.example.org' | head -1 # a redirect to https
|
|
```
|
|
|
|
## 8. Check it from outside
|
|
|
|
From any machine that is not the Pi:
|
|
|
|
```bash
|
|
curl -s https://mcp.example.org/healthz
|
|
# {"status":"ok","sessions":0,"index":"on"}
|
|
|
|
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
|
|
|
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/$(openssl rand -hex 32)/mcp \
|
|
-H 'content-type: application/json' -d '{}'
|
|
# 404 ← a wrong path secret, answered like any unknown path
|
|
```
|
|
|
|
Then confirm that TLS really ends on the Pi: the certificate the world sees is
|
|
the one Caddy there obtained.
|
|
|
|
```bash
|
|
echo | openssl s_client -connect mcp.example.org:443 -servername mcp.example.org 2>/dev/null \
|
|
| openssl x509 -noout -issuer -enddate # from outside
|
|
docker logs <caddy-container> 2>&1 | grep -i 'certificate obtained' | tail -1 # on the Pi
|
|
```
|
|
|
|
## 9. Connect Claude and the CLI
|
|
|
|
**CLI**, on your laptop:
|
|
|
|
```bash
|
|
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
|
|
schulcloud token # expires … (29 day(s) left); session alive, 120 min budget; from TSC_JWT_COOKIE
|
|
schulcloud status # after the first crawl: generation 1 — crawled … min ago
|
|
```
|
|
|
|
**Claude Code**, replacing the registration that points at the laptop:
|
|
|
|
```bash
|
|
claude mcp remove --scope user schulcloud
|
|
claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \
|
|
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
|
|
claude mcp list # schulcloud: https://mcp.example.org/mcp (HTTP) - ✔ Connected
|
|
```
|
|
|
|
**claude.ai:**
|
|
|
|
1. *Customize → Connectors → Add custom connector*. Name: `Schulcloud`. URL:
|
|
`https://mcp.example.org/mcp`.
|
|
2. The next step shows **No sign-in** as detected — keep it — and a **Request
|
|
headers** section. Add one: name `authorization`, value
|
|
`Bearer <MCP_CONNECTOR_TOKEN>`, with the space. Add the connector.
|
|
3. In a chat: **+ → Connectors** → switch *Schulcloud* on, and ask *"Welche Kurse
|
|
habe ich?"*
|
|
|
|
claude.ai stores the header and never shows it again; to change it, remove the
|
|
connector and add it again.
|
|
|
|
*Without request headers* — say, for a client that cannot send them — use a
|
|
secret path instead:
|
|
|
|
```bash
|
|
echo "MCP_PATH_SECRET=$(openssl rand -hex 32)" >> .env
|
|
docker compose up -d --force-recreate schulcloud-mcp
|
|
```
|
|
|
|
The URL is then `https://mcp.example.org/<MCP_PATH_SECRET>/mcp`, with no header.
|
|
It is the credential itself, so keep it out of screenshots and notes;
|
|
[DEPLOYMENT.md](DEPLOYMENT.md#without-request-headers--a-secret-path) says what
|
|
that trades away.
|
|
|
|
**Retire the laptop's container** once the Pi answers — in the laptop checkout,
|
|
`docker compose down` keeps its index and mirror volumes. Its session is separate
|
|
and simply lapses.
|
|
|
|
## 10. Verify the first crawl
|
|
|
|
```bash
|
|
docker compose logs schulcloud-mcp | grep 'scheduled crawl'
|
|
# [schulcloud-mcp] scheduled crawl: generation 1, 1030 files, … newly extracted, …s
|
|
```
|
|
|
|
A second crawl runs every six hours and downloads only what is new.
|
|
|
|
## 11. The monthly token
|
|
|
|
The token lasts 30 days. From a week before, the log, `whoami` and
|
|
`schulcloud token` warn. Replacing it takes a minute and no restart:
|
|
|
|
1. Private window → log in → copy the `jwt` cookie (as in step 4).
|
|
2. `schulcloud token set` and paste it — or open `https://mcp.example.org/token`
|
|
and paste it there with `MCP_AUTH_TOKEN`.
|
|
3. Close the private window.
|
|
|
|
The server checks the token with Schulcloud before using it, restarts the
|
|
keepalive, and saves it in the `state` volume, where it outlives restarts and
|
|
takes precedence over the older one in `.env`.
|
|
|
|
The same steps revive a session that lapsed — after a power cut of more than two
|
|
hours, say.
|
|
|
|
## Updating
|
|
|
|
A new version is published first, from a development machine:
|
|
`npm run publish-image` ([DEPLOYMENT.md](DEPLOYMENT.md#publishing-an-image)).
|
|
Then, on the Pi:
|
|
|
|
```bash
|
|
cd /opt/schulcloud-mcp
|
|
git pull # compose files and docs
|
|
docker compose pull # the newly published image
|
|
docker compose up -d
|
|
docker compose logs --tail 20 schulcloud-mcp
|
|
```
|
|
|
|
`COMPOSE_FILE` in `.env` keeps applying the Pi settings. Recreating the container
|
|
keeps the database, the mirror and a replaced token, all of which live in volumes.
|
|
|
|
With `SCHULCLOUD_MCP_TAG` pinned, set it to the new commit id before pulling.
|
|
Going back works the same way: set the previous commit id, then `pull` and
|
|
`up -d`.
|
|
|
|
## Backups
|
|
|
|
| What | Needed? |
|
|
|---|---|
|
|
| `.env` | **Yes** — it holds every secret. Only ever back it up encrypted. |
|
|
| Postgres | Optional: a crawl rebuilds it. `docker compose exec postgres pg_dump -U schulcloud schulcloud \| gzip > index.sql.gz` |
|
|
| `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. |
|
|
| `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. |
|
|
|
|
## Troubleshooting
|
|
|
|
| Symptom | Likely cause | Fix |
|
|
|---|---|---|
|
|
| `keepalive: token rejected (401)` | The session ended: the Pi was off for more than two hours, a Schulportal tab was left open, or 30 days passed | Step 11 |
|
|
| 401 about two hours after pasting a token | A Schulportal tab still open on that login | Close it, then step 11 |
|
|
| claude.ai cannot add the connector | DNS, forwarding or certificate not in place; a missing or mistyped request header (401); or, with a secret path, a wrong secret (404) | Step 8's checks, in order, then the header |
|
|
| The connector token works on `/mcp` but not with the CLI | By design: it is refused on `/api` | The CLI uses `MCP_AUTH_TOKEN` |
|
|
| claude.ai connects, then tools hang | `flush_interval -1` missing from the Caddy site | Step 6 |
|
|
| `/mcp` answers 401 with the right token | Whitespace copied along with the token | Re-copy it |
|
|
| Caddy never obtains a certificate | Port 80 not forwarded, or DNS not yet pointing at the VPS | Step 7 |
|
|
| `network … declared as external, but could not be found` | Wrong `CADDY_NETWORK` | Step 3 |
|
|
| `required variable … is missing a value` | A line missing from `.env` | Step 3 |
|
|
| `unauthorized` or `pull access denied` from `docker compose pull` | Not logged in to the registry on the Pi | `docker login registry.mc02.dev` |
|
|
| `no matching manifest for linux/arm64` | The image was published for amd64 only | Publish again with `npm run publish-image`, which builds both |
|
|
| Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) |
|
|
| `WebUntis rejected the server's key` from a untis_* tool | The key was regenerated in WebUntis, or `UNTIS_USER` does not match it | Copy both again from Profil → Freigaben → Untis Mobile, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
|
| `the server's clock is too far off` from a untis_* tool | The Pi's clock has drifted; the Untis code is time-based | `timedatectl status`, then fix NTP |
|
|
| `EACCES` for `/data/state` or `/data/mirror` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`), `docker compose up -d` |
|
|
|
|
## Security checklist
|
|
|
|
- `.env` is mode 600 and in no unencrypted backup.
|
|
- `docker compose ps` shows no published ports for `schulcloud-mcp` or `schulcloud-mcp-db`.
|
|
- The registry login in `~/.docker/config.json` is only base64-encoded. Keep the
|
|
Pi user's home private, and use an account that may only pull if your
|
|
registry can issue one.
|
|
- The VPS forwards raw TCP and opens only 80, 443, 51820/udp and SSH.
|
|
- With a secret path set, it stays out of Caddy's access log — this prints a
|
|
count, never the secret:
|
|
```bash
|
|
docker exec <caddy-container> grep -c "$(grep '^MCP_PATH_SECRET=' .env | cut -d= -f2)" /var/log/caddy/schulcloud-mcp.log
|
|
# 0
|
|
```
|
|
- If claude.ai's token may have leaked: set a new `MCP_CONNECTOR_TOKEN`, run
|
|
`docker compose up -d --force-recreate schulcloud-mcp`, and re-add the
|
|
connector with the new header. A leaked secret path is replaced the same way.
|
|
`MCP_AUTH_TOKEN` stays valid either way.
|