Add a setup guide for the Pi, and a compose file its .env selects
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
23
deploy/docker-compose.pi.yml
Normal file
23
deploy/docker-compose.pi.yml
Normal file
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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
|
||||
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}}' <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>
|
||||
```
|
||||
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.
|
||||
|
||||
387
docs/PI.md
Normal file
387
docs/PI.md
Normal file
@@ -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}}' <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_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=<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 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-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 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 <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
|
||||
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 = <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*.
|
||||
2. Name: `Schulcloud`. URL: `https://mcp.example.org/<MCP_PATH_SECRET>/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 <caddy-container> 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.
|
||||
Reference in New Issue
Block a user