From bac91303bf05f829c40937df8544c79eb472a921 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 20:19:17 +0200 Subject: [PATCH] Add a setup guide for the Pi, and a compose file its .env selects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/PI.md goes from a Pi with Docker to a working claude.ai connector: configuration, the first token, Caddy, DNS, the VPS forwarding raw TCP, checks from outside, connecting the clients, the monthly token, updates, backups and troubleshooting. docker-compose.override.yml is tracked, so Compose would have merged it on the Pi as well — publishing ports and switching the crawl timer off. The Pi's .env sets COMPOSE_FILE to add deploy/docker-compose.pi.yml instead, which joins the existing Caddy network by name and builds DATABASE_URL, so nothing tracked needs editing there. Postgres moves to a private network shared only with the server. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 +- README.md | 5 +- deploy/docker-compose.pi.yml | 23 +++ docker-compose.override.yml | 7 +- docker-compose.yml | 25 ++- docs/DEPLOYMENT.md | 63 +++--- docs/PI.md | 387 +++++++++++++++++++++++++++++++++++ 7 files changed, 460 insertions(+), 54 deletions(-) create mode 100644 deploy/docker-compose.pi.yml create mode 100644 docs/PI.md diff --git a/CLAUDE.md b/CLAUDE.md index 9b3c8e1..9ae2d93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,9 @@ npm run session-diagnose # ~2.5h: measure what actually ends the session `docker-compose.override.yml` is local-only and publishes the server on `127.0.0.1:8080` (or `MCP_HOST_PORT`) and Postgres on `127.0.0.1:55432`; see -`docs/LOCAL.md`. When `.env` points at the live account, test against the local +`docs/LOCAL.md`. Compose merges it whenever `COMPOSE_FILE` is unset, so the Pi's +`.env` sets `COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml` +instead; `docs/PI.md` is the setup guide. When `.env` points at the live account, test against the local instance only through `local-instance/scripts/mcp-env.sh`: it pins its own database (`schulcloud_local`) and mirror, so fixtures cannot reach the live index. diff --git a/README.md b/README.md index 85c4e12..46f24aa 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,9 @@ npm run probe # verifies the token and API against the live instan ``` To try it on your own machine — Docker stack, Claude Code, and the CLI — follow -[docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy, see -[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). +[docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy and a VPS, and +connect claude.ai, follow [docs/PI.md](docs/PI.md); +[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) explains the pieces. Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days — provided you close the Schulportal window afterwards. See diff --git a/deploy/docker-compose.pi.yml b/deploy/docker-compose.pi.yml new file mode 100644 index 0000000..d80c2aa --- /dev/null +++ b/deploy/docker-compose.pi.yml @@ -0,0 +1,23 @@ +# The Pi's additions to docker-compose.yml — see docs/PI.md. +# +# Selected by one line in the Pi's .env, so plain `docker compose` commands use it: +# +# COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml +# +# Setting COMPOSE_FILE also keeps docker-compose.override.yml out. That file is +# for local development only, and Compose would otherwise merge it silently: it +# publishes ports and turns the crawl timer off. + +services: + schulcloud-mcp: + environment: + # The bundled Postgres, by name on the private backend network. Overrides + # any DATABASE_URL in .env. + DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/schulcloud + +networks: + caddy: + # Join the network the Pi's Caddy container is already on, so Caddy reaches + # this server by name and no host port is published. + external: true + name: ${CADDY_NETWORK:?set CADDY_NETWORK in .env to the Docker network of your Caddy container} diff --git a/docker-compose.override.yml b/docker-compose.override.yml index a412a89..9dcacbb 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,6 +1,7 @@ -# Local development only. Compose merges this automatically; it is not used on -# the Pi, where Caddy reaches the container over the shared Docker network and -# no host port is published. +# Local development only. Compose merges this automatically whenever +# COMPOSE_FILE is unset — which is why the Pi's .env sets COMPOSE_FILE to +# deploy/docker-compose.pi.yml instead (docs/PI.md): there, Caddy reaches the +# container over the shared Docker network and no host port is published. services: postgres: ports: diff --git a/docker-compose.yml b/docker-compose.yml index 72f8b7a..bb0c3f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,13 @@ -# Standalone Compose file for the Pi. +# The server and its Postgres. # -# If you already run Caddy and PostgreSQL from another Compose project, either -# merge the `schulcloud-mcp` service below into that project's file, or keep -# this file separate and attach it to the existing Caddy network — see the -# `networks` block at the bottom and deploy/Caddyfile.snippet. +# Locally, docker-compose.override.yml is merged in automatically (docs/LOCAL.md). +# On the Pi, .env selects deploy/docker-compose.pi.yml instead, which attaches +# the server to the network of the Caddy already running there (docs/PI.md). services: - # Dev/standalone Postgres. On the Pi, point DATABASE_URL at the existing - # instance instead and remove this service — the schema lives in its own - # database and user, so it coexists with whatever else is already there. + # The index's own Postgres, on a private network with the server. An existing + # instance works too — point DATABASE_URL at it and drop this service — but + # nothing requires sharing one. postgres: image: postgres:17-alpine container_name: schulcloud-mcp-db @@ -19,8 +18,10 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-schulcloud} volumes: - pgdata:/var/lib/postgresql/data + # Only the server talks to Postgres. Keeping it off the Caddy network keeps + # it out of reach of whatever else shares that network on the Pi. networks: - - caddy + - backend healthcheck: test: ["CMD-SHELL", "pg_isready -U schulcloud -d schulcloud"] interval: 10s @@ -54,6 +55,7 @@ services: - "8080" networks: - caddy + - backend logging: driver: json-file options: @@ -73,8 +75,9 @@ volumes: state: networks: + backend: caddy: - # Set to true once this joins the network your existing Caddy already uses, - # and change the name to match (`docker network ls` to find it). + # On the Pi, deploy/docker-compose.pi.yml makes this the network your + # existing Caddy already uses (see docs/PI.md). external: false name: caddy diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6c9d747..813aa87 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,5 +1,8 @@ # Deployment +This page explains the moving parts and why each is there. For the steps in +order, on the Pi, follow [PI.md](PI.md). + ## The shape of it ``` @@ -35,15 +38,11 @@ this server's bearer check. ## First deploy +[PI.md](PI.md) steps 2–5: clone to `/opt/schulcloud-mcp`, write `.env` (the Pi's +`COMPOSE_FILE`, `CADDY_NETWORK` and `POSTGRES_PASSWORD`, generated secrets, the +first Schulcloud token), then: + ```bash -git clone /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 -openssl rand -hex 32 # → MCP_PATH_SECRET, only for claude.ai (see "Connecting Claude") - docker compose up -d --build docker compose logs -f schulcloud-mcp ``` @@ -51,7 +50,7 @@ docker compose logs -f schulcloud-mcp Expect: ``` -[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled, keepalive every 30min +[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled (plus secret MCP path), token from environment, 29 day(s) left, keepalive every 30min, index every 6h ``` `auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing @@ -61,39 +60,29 @@ will run but every tool will fail. ## 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. +The Pi already runs Caddy in a container. This server needs no Caddy of its own +— only to sit on the same Docker network, so Caddy reaches it by name and no +host port is published. -Find the network Caddy is on: - -```bash -docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' -``` - -Then in `docker-compose.yml`, set that name and mark it external: - -```yaml -networks: - caddy: - external: true - name: -``` +That is what `deploy/docker-compose.pi.yml` does, and the Pi's `.env` selects it +with `COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml`, so plain +`docker compose` commands pick it up and nothing tracked needs editing. It +declares the Caddy network external under the name in `CADDY_NETWORK`. Setting +`COMPOSE_FILE` also matters for what it leaves out: without it, Compose merges +`docker-compose.override.yml`, which is for local development and publishes +ports. ### Postgres -The index needs a database. On the Pi, use the existing PostgreSQL rather than -the container in `docker-compose.yml` — create a database and user for it: +The index gets its own Postgres container, on a network shared with this server +and nothing else — not with Caddy, and not with whatever else is on Caddy's +network. The Pi file builds `DATABASE_URL` from `POSTGRES_PASSWORD`. Migrations +run at startup; the first creates `pg_trgm`. -```sql -CREATE USER schulcloud WITH PASSWORD '…'; -CREATE DATABASE schulcloud OWNER schulcloud; -``` - -Then set `DATABASE_URL` in `.env` and delete the `postgres` service from the -compose file. Migrations run automatically at startup; `pg_trgm` is created by -the first migration, which needs the database owner to be able to -`CREATE EXTENSION`. +An existing Postgres works as well: create a database and a user that owns it +(it must be able to `CREATE EXTENSION`), point `DATABASE_URL` at it, and remove +the `postgres` service and the `depends_on` that names it. Nothing requires +sharing one. Without `DATABASE_URL` the server still runs: search crawls live on every call and `/api` returns `503`. The startup log says which mode it is in. diff --git a/docs/PI.md b/docs/PI.md new file mode 100644 index 0000000..d91e6bb --- /dev/null +++ b/docs/PI.md @@ -0,0 +1,387 @@ +# 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. + +``` +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** on the Pi. +- **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 +timedatectl # "System clock synchronized: yes" +``` + +The clock matters: token expiry and TLS certificates are both judged by it. + +## 2. Get the code + +```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`. + +## 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}}' +``` + +Create `.env`, readable by you alone, and add the Pi's settings with freshly +generated secrets — replace `` 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 < +POSTGRES_PASSWORD=$(openssl rand -hex 24) +MCP_AUTH_TOKEN=$(openssl rand -hex 32) +MCP_PATH_SECRET=$(openssl rand -hex 32) +INDEX_PERSONAL_FILES=true +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 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_PATH_SECRET` | The secret path claude.ai uses until OAuth exists. | +| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. | + +**Copy `MCP_AUTH_TOKEN` and `MCP_PATH_SECRET` 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_PATH_SECRET)=' .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=`. +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 up -d --build # the first build takes a few minutes on a Pi +docker compose ps +docker compose logs -f schulcloud-mcp +``` + +Expect, within a few seconds: + +``` +[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus secret MCP path), 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 validate --config /etc/caddy/Caddyfile +docker exec 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 the secret path out of the access log. + +Caddy gets its certificate once DNS and the forwarding work (step 7). Watch for +it with `docker logs -f | 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 +path — including the secret one — 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 = + +[Peer] +PublicKey = +AllowedIPs = 10.8.0.2/32 +``` + +On the Pi, `/etc/wireguard/wg0.conf`: + +```ini +[Interface] +Address = 10.8.0.2/24 +PrivateKey = + +[Peer] +PublicKey = +Endpoint = :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 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 +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 " +claude mcp list # schulcloud: https://mcp.example.org/mcp (HTTP) - ✔ Connected +``` + +**claude.ai:** + +1. *Customize → Connectors → Add custom connector*. +2. Name: `Schulcloud`. URL: `https://mcp.example.org//mcp`. No sign-in. +3. In a chat: **+ → Connectors** → switch *Schulcloud* on, and ask *"Welche Kurse + habe ich?"* + +The URL is the credential for as long as the secret path is in use: keep it out +of screenshots and notes. [DEPLOYMENT.md](DEPLOYMENT.md#claudeai--a-secret-path-for-now) +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 + +```bash +cd /opt/schulcloud-mcp +git pull +docker compose up -d --build +docker compose logs --tail 20 schulcloud-mcp +``` + +`COMPOSE_FILE` in `.env` keeps applying the Pi settings. A rebuild keeps the +database, the mirror and a replaced token, all of which live in volumes. + +## 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, or a wrong secret (404) | Step 8's checks, in order | +| 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 | +| `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 VPS forwards raw TCP and opens only 80, 443, 51820/udp and SSH. +- The secret path stays out of Caddy's access log — this prints a count, never + the secret: + ```bash + docker exec grep -c "$(grep '^MCP_PATH_SECRET=' .env | cut -d= -f2)" /var/log/caddy/schulcloud-mcp.log + # 0 + ``` +- If the claude.ai URL may have leaked: set a new `MCP_PATH_SECRET`, run + `docker compose up -d --force-recreate schulcloud-mcp`, and re-add the connector.