Read-only MCP server exposing a Schulcloud account to Claude: courses,
column boards, lessons, tasks, and file downloads with text extraction.
The API surface was verified against the live instance rather than
inferred from upstream source, which changed several design decisions:
- The `jwt` cookie works verbatim as `Authorization: Bearer` and lasts 30
days, so there is no cookie jar and no refresh-session timer.
- Course contents live at /api/v3/course-rooms/{courseId}/board; there is
no GET /api/v3/courses/{id}.
- Files are a separate service (/api/v3/file/*) with its own OpenAPI doc.
- Board file elements carry no file id; attachments are resolved by
listing files-storage with parentType=boardnodes and the element id.
Read-only by construction: every client method is a GET, including the
api_get escape hatch. The endpoint is internet-facing by necessity, so a
leaked token being unable to act as the user is the key safety property.
Deploys as a container behind the Pi's existing Caddy, guarded by a
constant-time bearer check. Stateless — no database.
Verified: 28 unit tests, plus a 30-check end-to-end run driving a real
MCP client over Streamable HTTP against the live account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
4.9 KiB
Markdown
158 lines
4.9 KiB
Markdown
# Deployment
|
||
|
||
## The shape of it
|
||
|
||
```
|
||
claude.ai ──HTTPS──▶ VPS (public IP) ──tunnel──▶ Pi 5 (home network)
|
||
└─ Caddy ──▶ schulcloud-mcp:8080
|
||
│
|
||
└──▶ schulcloud-thueringen.de
|
||
```
|
||
|
||
Claude's custom connectors call the endpoint from Anthropic's cloud, 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 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
|
||
```
|
||
|
||
`auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing
|
||
the service.
|
||
|
||
## 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>
|
||
```
|
||
|
||
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.
|
||
|
||
## 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. It writes
|
||
nothing to disk — downloads are streamed through memory, capped at
|
||
`MAX_DOWNLOAD_BYTES` (25 MiB default).
|
||
- **Monthly chore**: refresh `TSC_JWT_COOKIE`. `npm run probe` tells you how
|
||
many days are left.
|