Files
Schulcloud-MCP/docs/LOCAL.md
MechaCat02 973b82ebf5 Replace the Schulcloud token without a restart
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>
2026-09-16 20:19:16 +02:00

134 lines
4.6 KiB
Markdown

# Running it locally
Everything runs on your machine: Postgres, the server, the CLI, and Claude Code
talking to all of it. Only Schulcloud itself is remote.
## Start the stack
```bash
cp .env.example .env # fill in TSC_URL, TSC_JWT_COOKIE, MCP_AUTH_TOKEN
npm install && npm run build
docker compose up -d --build
curl -s http://127.0.0.1:8080/healthz # {"status":"ok","sessions":0,"index":"on"}
```
`docker-compose.override.yml` is merged automatically and is **local-only**: it
publishes the server on `127.0.0.1:8080` and Postgres on `127.0.0.1:55432`, and
switches crawling to on-demand. Two `.env` settings adjust it: `MCP_HOST_PORT`
moves the published port when 8080 is taken, and `CRAWL_INTERVAL_MS` restores a
crawl timer — worth doing against a real account, because `what_changed` can
only report what happened between crawls.
A fresh Schulcloud token goes in with `schulcloud token set`, or on the page at
`http://127.0.0.1:8080/token` — no restart; see docs/CLI.md. Editing
`TSC_JWT_COOKIE` in `.env` instead needs the container **recreated**, because
`env_file` is read when the container is created, not on restart:
```bash
docker compose up -d --force-recreate schulcloud-mcp
```
On the Pi neither port is published — Caddy reaches the container over the
Docker network.
Loopback binding is deliberate. The bearer token is the only thing in front of
your account's data, so it should not be listening on your LAN while you test.
## Populate the index
A full crawl is ~270 requests; start with one course:
```bash
node dist/bin/cli.js login --server http://127.0.0.1:8080 --token "$MCP_AUTH_TOKEN"
node dist/bin/cli.js refresh --course <courseId> # or omit --course for everything
node dist/bin/cli.js status
```
Get a course id from `curl -s -H "Authorization: Bearer $TSC_JWT_COOKIE" \
"$TSC_URL/api/v3/courses?limit=5" | jq -r '.data[] | "\(.id) \(.title)"'`.
## Connect Claude Code
Claude Code supports MCP over stdio, SSE and HTTP. The VS Code extension runs
the same Claude Code underneath, so it reads the same configuration — register
once and both work.
**Use `--scope user`.** Claude Code's default scope is `local`, which is
*per-project*: the server is stored under that directory in `~/.claude.json` and
`claude mcp list` will not show it from anywhere else. Since the point is to ask
about your coursework from wherever you happen to be, user scope is what you
want. (`project` scope would write a `.mcp.json` into the repo — wrong here,
because the HTTP config carries a token.)
**HTTP — matches the deployed setup:**
```bash
claude mcp add --scope user --transport http schulcloud http://127.0.0.1:8080/mcp \
--header "Authorization: Bearer $MCP_AUTH_TOKEN"
```
**stdio — no server or Docker needed:**
```bash
claude mcp add --scope user schulcloud-stdio \
-e DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud \
-- node --env-file=/absolute/path/to/.env /absolute/path/to/dist/bin/stdio.js
```
`--env-file` keeps the JWT in `.env` rather than copying it into the MCP config.
The HTTP form has no equivalent — its header holds the token — so that token
lives in `~/.claude.json`.
Check it:
```bash
claude mcp list # schulcloud: http://127.0.0.1:8080/mcp (HTTP) - ✔ Connected
claude mcp get schulcloud
```
If `claude mcp list` does not show it, the usual cause is scope: run
`claude mcp list` from the directory you added it in, and if it appears there,
re-add it with `--scope user`.
Then just ask: *"which courses am I in?"*, *"what's due this week?"*, *"find the
material about Verschlüsselung"*. Inside a session, `/mcp` lists the servers and
their tools.
## Test the CLI
```bash
node dist/bin/cli.js ls --long
node dist/bin/cli.js sync --dry-run # shows what it would mirror
node dist/bin/cli.js sync
```
`npm link` puts it on your PATH as `schulcloud`.
## Run the test suites
```bash
npm test # 174 offline tests
npm run smoke # end-to-end against the live instance, live-only mode
DATABASE_URL=… npm run smoke # end-to-end with the index (74 checks)
```
Store tests need a database and skip without one:
```bash
docker exec schulcloud-mcp-db psql -U schulcloud -d schulcloud \
-c "CREATE DATABASE schulcloud_test OWNER schulcloud"
TEST_DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_test npm test
```
**These tests `TRUNCATE`.** They refuse to run unless the database name contains
"test", because aiming them at the dev database once was enough — the fixtures
ended up in real data.
## Tearing down
```bash
docker compose down # keep the index and mirror
docker compose down -v # discard them too
claude mcp remove schulcloud
```