claude.ai's connector dialog does offer request headers, on its second step, after the URL has been probed, so the connector no longer needs the secret path. MCP_AUTH_TOKEN already worked there as a bearer or X-Api-Key, but it also opens /api, which can replace the Schulcloud token and stream the file mirror, and claude.ai stores the header's value. MCP_CONNECTOR_TOKEN is a second token, accepted on /mcp only and refused on /api, and rotated without touching Claude Code or the CLI. The config refuses one shorter than 32 characters, equal to MCP_AUTH_TOKEN, or set without it, and never echoes a value. Every accepted token is compared in full, so the timing does not tell which one matched. The gate also takes a bare Authorization value, because claude.ai sends a header exactly as typed and its docs warn that most servers reject a token entered without "Bearer ". It takes X-Auth-Token too, the other name its dialog offers. The docs now set up the header; the secret path stays as a fallback for clients that cannot send one. 184 tests. Smoke 79/79 and 77/77 on the local instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
310 lines
14 KiB
Markdown
310 lines
14 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, whose header or path carries a credential (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 1–5: log in to `registry.mc02.dev`, clone to
|
||
`/opt/schulcloud-mcp` for the compose files, write `.env` (the Pi's
|
||
`COMPOSE_FILE`, `CADDY_NETWORK` and `POSTGRES_PASSWORD`, generated secrets, the
|
||
first Schulcloud token), then:
|
||
|
||
```bash
|
||
docker compose pull
|
||
docker compose up -d
|
||
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 request header with a token of its own
|
||
|
||
claude.ai's *Add custom connector* dialog asks for a name and a URL first. Its
|
||
authentication settings, **Request headers** among them, appear on the next
|
||
step, once it has probed the URL.
|
||
|
||
1. Put `MCP_CONNECTOR_TOKEN=<openssl rand -hex 32>` in `.env` and recreate the
|
||
container: `docker compose up -d --force-recreate schulcloud-mcp`. The
|
||
startup line then says `(plus connector token)`.
|
||
2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and
|
||
give the URL `https://mcp.example.org/mcp`.
|
||
3. On the next step keep **No sign-in**, which is what Claude detects, and add a
|
||
request header: name `authorization`, value `Bearer <MCP_CONNECTOR_TOKEN>`,
|
||
space included. A bare token, or the header `x-api-key`, works too.
|
||
4. Enable it in a conversation via **+ → Connectors**.
|
||
|
||
Ask *"which courses am I in?"* as a first check — that exercises the header,
|
||
the Schulcloud token and the API in one call.
|
||
|
||
**Why a token of its own.** claude.ai stores the header value, so the token is
|
||
a credential held by a third party. `MCP_CONNECTOR_TOKEN` opens `/mcp`, the
|
||
read-only tools, and is refused on `/api`, which can replace the Schulcloud
|
||
token and stream the file mirror. It also rotates alone: set a new value,
|
||
recreate the container, then remove the connector and add it again, because
|
||
claude.ai cannot edit a stored header. Claude Code and the CLI are unaffected.
|
||
|
||
### Without request headers — a secret path
|
||
|
||
For a client that cannot send a header, 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. The startup line then says `(plus secret MCP path)` — never the
|
||
secret itself.
|
||
2. Give the client the URL `https://mcp.example.org/<secret>/mcp`, with no
|
||
header and no sign-in.
|
||
|
||
**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.
|
||
|
||
## Publishing an image
|
||
|
||
The Pi never builds: `deploy/docker-compose.pi.yml` runs
|
||
`registry.mc02.dev/schulcloud-mcp` and removes the build section it would
|
||
otherwise inherit. Images are published from a development machine:
|
||
|
||
```bash
|
||
npm run publish-image
|
||
docker buildx imagetools inspect registry.mc02.dev/schulcloud-mcp:latest # lists linux/amd64 and linux/arm64
|
||
```
|
||
|
||
`scripts/publish-image.sh` refuses a working tree with uncommitted changes, so
|
||
a tag always names exactly the code of one commit. It builds for `linux/amd64`
|
||
and `linux/arm64` and pushes two tags: `latest`, and the short commit id. It
|
||
also labels the image with the full commit and the repository.
|
||
|
||
Two things the build machine needs:
|
||
|
||
- **arm64 emulation on an x86 machine.** The script checks for it and prints
|
||
the command that registers QEMU —
|
||
`docker run --privileged --rm tonistiigi/binfmt --install arm64` — which lasts
|
||
until the next reboot.
|
||
- **A multi-platform push.** Docker's containerd image store supports it
|
||
directly. Without that store, create a builder first with
|
||
`docker buildx create --use`.
|
||
|
||
## Updating
|
||
|
||
```bash
|
||
npm run publish-image # on a development machine
|
||
cd /opt/schulcloud-mcp && git pull # on the Pi: compose files and docs
|
||
docker compose pull && docker compose up -d
|
||
npm run probe # on a development machine: re-verify the API assumptions
|
||
```
|
||
|
||
A `.env` that pins `SCHULCLOUD_MCP_TAG` needs the new commit id before the pull.
|
||
Setting the previous one rolls back.
|
||
|
||
## 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.
|