Local dev setup; fix mirror volume ownership and a test footgun

Adds docker-compose.override.yml (local-only: publishes the server on
127.0.0.1:8080 and Postgres on 127.0.0.1:55432, crawls on demand) and
docs/LOCAL.md covering the stack, Claude Code registration over both
transports, the CLI, and the test suites.

Two bugs that only running the real container could find:

The mirror volume was root-owned while the container runs as node, so
every file write failed with EACCES. Docker initialises a named volume
from the image directory including its ownership, so the fix is to
create /data/mirror owned by node in the image. This was easy to miss
because the indexer records a per-file failure rather than crashing —
the crawl "succeeded" with 4 skipped. Earlier direct-node testing missed
it entirely by writing to a scratch dir owned by the developer.

The store tests TRUNCATE, and pointing TEST_DATABASE_URL at the dev
database put their fixtures into real data. They now refuse any database
whose name does not contain "test".

Verified against the rebuilt container: 4 files mirrored, image-only PDF
detection firing in the real pipeline, both transports showing
✔ Connected in `claude mcp list`, and a whoami tool call driven end to
end through `claude -p`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 21:57:35 +02:00
parent 359c46afad
commit e6f2258df9
6 changed files with 166 additions and 7 deletions

View File

@@ -28,6 +28,9 @@ npm run keepalive-status # is the deployed container holding its session?
npm run session-diagnose # ~2.5h: measure what actually ends the session npm run session-diagnose # ~2.5h: measure what actually ends the session
``` ```
`docker-compose.override.yml` is local-only and publishes the server on
`127.0.0.1:8080` and Postgres on `127.0.0.1:55432`; see `docs/LOCAL.md`.
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are `probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`, read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
`src/mcp/` or `src/http/` — the unit tests cover only pure functions. `src/mcp/` or `src/http/` — the unit tests cover only pure functions.
@@ -39,7 +42,9 @@ fallback nobody exercises.
Store tests need a database and skip without one: Store tests need a database and skip without one:
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on `TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
purpose — the generation/diff semantics are entirely SQL, so a mock would test purpose — the generation/diff semantics are entirely SQL, so a mock would test
nothing. nothing. **They `TRUNCATE`**, and refuse to run unless the database name
contains "test"; that guard exists because pointing them at the dev database
once put fixtures into real data.
## Architecture ## Architecture

View File

@@ -30,7 +30,14 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY package.json ./ COPY package.json ./
# node:alpine ships an unprivileged `node` user; the process never writes to disk. # The mirror is the one writable path. Creating it in the image with the right
# owner matters: Docker initialises a new named volume from the image directory,
# including its ownership, so without this the volume lands root-owned and the
# unprivileged user gets EACCES on every write — with the failure recorded per
# file rather than crashing, which makes it easy to miss.
RUN mkdir -p /data/mirror && chown -R node:node /data
# node:alpine ships an unprivileged `node` user.
USER node USER node
EXPOSE 8080 EXPOSE 8080

View File

@@ -64,8 +64,9 @@ npm run build
npm run probe # verifies the token and API against the live instance npm run probe # verifies the token and API against the live instance
``` ```
Then either deploy it as a remote connector, or point Claude Code at To try it on your own machine — Docker stack, Claude Code, and the CLI — follow
`dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). [docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy, see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days — Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days —
provided you close the Schulportal window afterwards. See provided you close the Schulportal window afterwards. See

View File

@@ -0,0 +1,20 @@
# Local development only. Compose merges this automatically; it is not used on
# the Pi, where Caddy reaches the container over the shared Docker network and
# no host port is published.
services:
postgres:
ports:
# Loopback only. Lets `npm test` reach it via TEST_DATABASE_URL and lets
# the stdio server use the same index as the HTTP one.
- "127.0.0.1:55432:5432"
schulcloud-mcp:
environment:
# The bundled postgres service, reachable by name on the compose network.
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud
# Crawl on demand while developing rather than every 6 hours.
CRAWL_INTERVAL_MS: 0
ports:
# Bound to loopback: this exposes the account's data, and the bearer token
# is the only thing in front of it.
- "127.0.0.1:8080:8080"

109
docs/LOCAL.md Normal file
View File

@@ -0,0 +1,109 @@
# 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. 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.
**HTTP — matches the deployed setup:**
```bash
claude mcp add --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 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 such option — its header holds the token — so leave it at
the default `local` scope, which writes to `~/.claude.json` rather than a
`.mcp.json` that would be committed.
Check it:
```bash
claude mcp list # schulcloud: http://127.0.0.1:8080/mcp (HTTP) - ✔ Connected
claude mcp get schulcloud
```
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 # 58 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 (34 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
```

View File

@@ -8,7 +8,23 @@ import type { Snapshot } from '../src/core/crawl.ts';
* are entirely SQL, so a mock would test nothing. Skipped when TEST_DATABASE_URL * are entirely SQL, so a mock would test nothing. Skipped when TEST_DATABASE_URL
* is unset so `npm test` stays offline by default. * is unset so `npm test` stays offline by default.
*/ */
const URL = process.env.TEST_DATABASE_URL; const DB_URL = process.env.TEST_DATABASE_URL;
/**
* These tests TRUNCATE. Pointing them at a real database destroys it — which
* happened once during development, when TEST_DATABASE_URL was aimed at the dev
* instance and the fixtures ended up in live data. Requiring "test" in the
* database name makes that mistake impossible to repeat by accident.
*/
function assertDisposable(url: string): void {
const name = new globalThis.URL(url).pathname.replace(/^\//, '');
if (!/test/i.test(name)) {
throw new Error(
`Refusing to run: TEST_DATABASE_URL points at database "${name}", which is not obviously ` +
`disposable. These tests TRUNCATE. Use a database with "test" in its name.`,
);
}
}
function snapshot(courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[]): Snapshot { function snapshot(courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[]): Snapshot {
return { return {
@@ -38,11 +54,12 @@ function snapshot(courses: { id: string; title: string; boardText?: string; file
}; };
} }
describe('Store', { skip: URL ? false : 'set TEST_DATABASE_URL to run' }, () => { describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, () => {
let store: Store; let store: Store;
before(async () => { before(async () => {
const opened = await Store.open(URL); assertDisposable(DB_URL!);
const opened = await Store.open(DB_URL);
assert.ok(opened, 'store should open'); assert.ok(opened, 'store should open');
store = opened; store = opened;
// Start from a clean slate so generation ids are predictable. // Start from a clean slate so generation ids are predictable.