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:
@@ -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
|
||||
```
|
||||
|
||||
`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
|
||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||
`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:
|
||||
`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
|
||||
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
|
||||
|
||||
|
||||
@@ -30,7 +30,14 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
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
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -64,8 +64,9 @@ npm run build
|
||||
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
|
||||
`dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
To try it on your own machine — Docker stack, Claude Code, and the CLI — follow
|
||||
[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 —
|
||||
provided you close the Schulportal window afterwards. See
|
||||
|
||||
20
docker-compose.override.yml
Normal file
20
docker-compose.override.yml
Normal 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
109
docs/LOCAL.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
* 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 {
|
||||
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;
|
||||
|
||||
before(async () => {
|
||||
const opened = await Store.open(URL);
|
||||
assertDisposable(DB_URL!);
|
||||
const opened = await Store.open(DB_URL);
|
||||
assert.ok(opened, 'store should open');
|
||||
store = opened;
|
||||
// Start from a clean slate so generation ids are predictable.
|
||||
|
||||
Reference in New Issue
Block a user