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>
259 lines
11 KiB
Markdown
259 lines
11 KiB
Markdown
# 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
|
||
|
||
```
|
||
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. `/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. **Keep the
|
||
VPS forwarding raw TCP** rather than terminating TLS itself: then it never sees
|
||
a request path, which matters once a path carries a secret (below).
|
||
|
||
**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
|
||
|
||
[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
|
||
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 (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
|
||
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 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.
|
||
|
||
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 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`.
|
||
|
||
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.
|
||
|
||
### 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.
|
||
- **The `format filter` in `log`** — rewrites `/<secret>/mcp` before an access
|
||
log entry is written. Without it every claude.ai request writes the secret to
|
||
disk. Verified against Caddy 2.11: the entry reads `"uri":"/<secret>/mcp"`.
|
||
|
||
## 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
|
||
|
||
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 looks like any unknown path
|
||
```
|
||
|
||
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
|
||
|
||
### claude.ai — a secret path, for now
|
||
|
||
claude.ai's *Add custom connector* dialog takes a name and a URL. Sending a
|
||
bearer token needs its "Request headers" section, a beta most accounts do not
|
||
have, and the proper answer — OAuth — is not built yet. Until it is, the
|
||
server can serve MCP at a path that is itself the secret:
|
||
|
||
1. Put `MCP_PATH_SECRET=<openssl rand -hex 32>` in `.env` and recreate the
|
||
container: `docker compose up -d --force-recreate schulcloud-mcp`. The
|
||
startup line then says `(plus secret MCP path)` — never the secret itself.
|
||
2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and
|
||
give the URL `https://mcp.example.org/<secret>/mcp`. No sign-in.
|
||
3. Enable it in a conversation via **+ → Connectors**.
|
||
|
||
Ask *"which courses am I in?"* as a first check — that exercises the path, the
|
||
Schulcloud token and the API in one call.
|
||
|
||
**Know what this trades away.** The URL is now the credential, and Anthropic's
|
||
connector documentation calls credentials in URLs a security vulnerability,
|
||
because URLs end up in logs. This deployment keeps it out of its own: the
|
||
server never logs request paths, the Caddy snippet rewrites the segment to
|
||
`<secret>` in the access log, and a VPS forwarding raw TCP never sees it.
|
||
Caddy's *error* log can still name the path if the container is down while
|
||
claude.ai calls, and claude.ai stores the URL in its connector settings. There
|
||
is no revocation short of a new secret: change `MCP_PATH_SECRET`, recreate the
|
||
container, and add the connector again. Anyone holding the URL can read — never
|
||
change — the account. A wrong secret answers 404, like any unknown path.
|
||
|
||
### Claude Code and the CLI — the bearer token
|
||
|
||
Both can send a header, so they keep using `MCP_AUTH_TOKEN` on the plain `/mcp`
|
||
and `/api`:
|
||
|
||
```bash
|
||
claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \
|
||
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
|
||
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
|
||
```
|
||
|
||
## 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.
|