A token lasts 30 days and only a browser login yields one — the account is federated, so the server cannot mint it. Replacing it meant editing .env and recreating the container, every month. `schulcloud token set` (a hidden prompt, or piped input) and a /token page both send it to PUT /api/token. The server checks it with Schulcloud first — well-formed, unexpired, still logged in, the same account — then swaps it into the config every request reads, restarts the keepalive and saves it in STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A refused paste changes nothing, and the token is never logged. The keepalive's pings carry a generation, so a 401 for the old token that arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami and the log report the expiry and warn a week ahead. Found on the way: a host that is off for more than two hours loses the session however long the token has left — this machine lost it overnight — which is what the always-on Pi is for. 174 tests. Smoke 72/72 on the local instance, and a real swap verified end to end there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
231 lines
8.8 KiB
Markdown
231 lines
8.8 KiB
Markdown
# Deployment
|
||
|
||
## The shape of it
|
||
|
||
```
|
||
claude.ai ──HTTPS──┐
|
||
├─▶ VPS (public IP) ──tunnel──▶ Pi 5 (home network)
|
||
schulcloud CLI ─────┘ └─ Caddy ─▶ schulcloud-mcp:8080
|
||
├─ /mcp (Claude)
|
||
├─ /api (CLI)
|
||
├─ Postgres (index)
|
||
├─ mirror (file bytes)
|
||
└──▶ schulcloud-thueringen.de
|
||
```
|
||
|
||
Both front ends use the same hostname and the same bearer token. `/mcp` speaks MCP; `/api` serves the
|
||
CLI's manifest, file bytes, re-crawl requests and token replacement; `/token` is
|
||
a page for pasting a fresh Schulcloud token.
|
||
|
||
Claude's custom connectors call the endpoint from Anthropic's cloud
|
||
(`160.79.104.0/21`), 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 Pi must stay up.** More than two hours offline ends the Schulcloud session
|
||
however long the token has left — a laptop that sleeps overnight loses it every
|
||
night. A replacement token fixes that without a restart (see *Replacing the
|
||
Schulcloud token*), but an always-on host is what avoids needing one.
|
||
|
||
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, keepalive every 30min
|
||
```
|
||
|
||
`auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing
|
||
the service. A `keepalive: token rejected (401)` line right after startup means
|
||
the Schulcloud token is dead and needs replacing (see docs/AUTH.md); the server
|
||
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.
|
||
|
||
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>
|
||
```
|
||
|
||
### 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:
|
||
|
||
```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`.
|
||
|
||
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.
|
||
|
||
### Caddy
|
||
|
||
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.
|
||
|
||
## Replacing the Schulcloud token
|
||
|
||
The token lasts 30 days at most and can only come from a browser login (see
|
||
docs/AUTH.md), so this is the monthly chore — but it no longer needs a restart
|
||
or an `.env` edit:
|
||
|
||
1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's
|
||
value (DevTools → Application → Cookies).
|
||
2. Either run `schulcloud token set` and paste it, or open
|
||
`https://mcp.example.org/token` and paste it together with `MCP_AUTH_TOKEN`.
|
||
3. **Close the private window.**
|
||
|
||
The server checks the token with Schulcloud first — right account, not
|
||
expired, not logged out — then swaps it in, restarts the keepalive and saves it
|
||
to the `state` volume (`STATE_DIR=/data/state`), so a restart keeps it. At
|
||
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, unless the two
|
||
are for different accounts: changing `TSC_JWT_COOKIE` is how you switch
|
||
accounts. `schulcloud token` shows the days left; from a week before expiry the
|
||
log, `whoami` and the CLI warn about it.
|
||
|
||
## 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. The writable paths are the mirror
|
||
volume at `/data/mirror`, which holds downloaded file bytes, and the state
|
||
volume at `/data/state`, which holds a replaced Schulcloud token (mode 0600).
|
||
Both belong in no backup that leaves the Pi unencrypted.
|
||
- **The mirror grows.** It holds a copy of every course file under
|
||
`MIRROR_MAX_BYTES` (64 MiB default). Larger files — videos, mostly — are
|
||
indexed as metadata and proxied live on request instead. Budget a few GB.
|
||
- **A re-crawl of unchanged content downloads nothing**, because Schulcloud file
|
||
records are immutable, so the 6-hourly crawl costs a few hundred cheap GETs in
|
||
the steady state.
|
||
- **The Schulcloud session has a 2-hour sliding TTL**, so the server calls
|
||
`refresh-session` every 30 minutes. Watch for
|
||
`keepalive: session extended, 7200s` in the logs, or run
|
||
`npm run keepalive-status` for a summary.
|
||
- **Never leave a Schulportal tab open on the token you deployed.** It shares
|
||
the session and its auto-logout will revoke it ~2h after login. Copy the
|
||
cookie in a private window and close it — see docs/AUTH.md.
|
||
- **Downtime longer than two hours lapses the session** and restarting does not
|
||
recover it: a long power cut means handing the server a fresh token
|
||
(`schulcloud token set`), no restart needed.
|
||
- **Monthly chore**: replace the token before its 30-day hard expiry — see
|
||
*Replacing the Schulcloud token*. `schulcloud token` and `npm run probe`
|
||
report the clocks.
|