diff --git a/.dockerignore b/.dockerignore index 1bfb9dd..c5997b7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,8 @@ scripts !scripts/copy-assets.mjs docs files.zip +# Mirrored coursework and server logs: never needed to build, and the mirror +# holds the account's files, which have no business in a build context. +tmp +# The local test instance has its own compose project. +local-instance diff --git a/.env.example b/.env.example index bacd13f..f5ddc4e 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,10 @@ MCP_AUTH_TOKEN= PORT=8080 BIND_HOST=0.0.0.0 +# Local Docker only: the loopback port docker-compose.override.yml publishes the +# container on. Default 8080; change it when that port is already taken. +# MCP_HOST_PORT=8080 + # --------------------------------------------------------------------------- # Index and file mirror (optional — without these the server runs live-only: # search crawls on every call, and the CLI's /api surface is unavailable) diff --git a/CLAUDE.md b/CLAUDE.md index 5bd17a5..00d554a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,11 @@ 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`. +`127.0.0.1:8080` (or `MCP_HOST_PORT`) and Postgres on `127.0.0.1:55432`; see +`docs/LOCAL.md`. When `.env` points at the live account, test against the local +instance only through `local-instance/scripts/mcp-env.sh`: it pins its own +database (`schulcloud_local`) and mirror, so fixtures cannot reach the live +index. `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/`, diff --git a/docker-compose.override.yml b/docker-compose.override.yml index ab63a59..a412a89 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -12,9 +12,12 @@ services: 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 + # On demand by default while developing. Set CRAWL_INTERVAL_MS in .env to + # crawl on a timer instead — what_changed can only report what happened + # between crawls, so against a real account on-demand leaves it empty. + CRAWL_INTERVAL_MS: ${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" + # MCP_HOST_PORT moves it when 8080 is already taken on this machine. + - "127.0.0.1:${MCP_HOST_PORT:-8080}:8080" diff --git a/docs/LOCAL.md b/docs/LOCAL.md index 16c6135..03422fe 100644 --- a/docs/LOCAL.md +++ b/docs/LOCAL.md @@ -14,7 +14,17 @@ curl -s http://127.0.0.1:8080/healthz # {"status":"ok","sessions":0,"index": `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 +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. + +After pasting a new `TSC_JWT_COOKIE` into `.env`, **recreate** the container — +`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 diff --git a/local-instance/README.md b/local-instance/README.md index 4ec37b2..e0ce209 100644 --- a/local-instance/README.md +++ b/local-instance/README.md @@ -193,6 +193,21 @@ eval "$(./scripts/mcp-env.sh)" # as the demo student cd .. && npm run smoke # 39 checks against the local instance ``` +`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the +mirror at `tmp/mirror-local` — not just the instance at this one. The root +`.env` normally targets the live account, and process env beats `--env-file`, so +without that a local smoke run would crawl these fixtures into the live index, +where a per-course refresh then carries them forward indefinitely. Create the +database once: + +```bash +docker exec schulcloud-mcp-db psql -U schulcloud -d postgres \ + -c "CREATE DATABASE schulcloud_local OWNER schulcloud" +``` + +Without it the server does not fail; it runs live-only, and smoke reports the +smaller, index-free check count. + It also builds a **room** ("Raum"): rooms are a separate space from courses, and the naming misleads — the sidebar's *Kurse* entry points at `/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the diff --git a/local-instance/scripts/mcp-env.sh b/local-instance/scripts/mcp-env.sh index 24ed797..323d057 100755 --- a/local-instance/scripts/mcp-env.sh +++ b/local-instance/scripts/mcp-env.sh @@ -23,8 +23,19 @@ token=$(curl -fsS -X POST "$URL/api/v3/authentication/local" \ "$(printf '%s' "$PASS" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])') +# The index and mirror are pinned too, not just the instance. The repo's .env +# normally points at the live account, and process env beats --env-file, so +# without these a local smoke run would crawl this throwaway instance straight +# into the live index — and a per-course refresh carries everything outside its +# scope forward, so the fixtures would outlive the run. Fixtures in real data +# is exactly the accident the store tests' "test" guard exists to prevent. +ROOT=$(cd "$(dirname "$0")/../.." && pwd) + cat < { + const legacy = await client.getLegacyCourse(firstCourse.id); + const parts = [ + legacy.description ? 'description' : null, + legacy.teacherIds?.length ? `${legacy.teacherIds.length} teacher id(s)` : null, + legacy.times?.length ? `${legacy.times.length} timetable slot(s)` : null, + ].filter(Boolean); + return parts.length > 0 ? parts.join(', ') : 'responds, but carries none of the fields'; + }); + } + await expect('GET /api/v1/users/{me} (the only id -> name route)', async () => { + const user = await client.getLegacyUser(me.user.id); + return user.firstName || user.fullName ? 'resolves your own name' : 'responds without a name'; + }); + const teacherId = firstCourse ? (await client.getLegacyCourse(firstCourse.id).catch(() => undefined))?.teacherIds?.[0] : undefined; + if (teacherId) { + // Expected to be refused for a student: names then degrade to "not + // visible to this account". Reported, not failed, either way. + const seen = await client.getLegacyUser(teacherId).then(() => 'readable', (error) => `refused (${error.status ?? error.message})`); + console.log(` — GET /api/v1/users/{teacher}: ${seen} — a student is expected to be refused`); + } + await expect('GET /api/v3/groups/class', async () => { + const classes = await client.listClasses(); + const named = classes.filter((entry) => entry.teacherNames?.length).length; + return `${classes.length} class(es), ${named} with teacher names`; + }); + const rooms = await client.listRooms().catch(() => []); + if (rooms[0]) { + await expect('room allowedOperations is an object, not a list', async () => { + const room = await client.getRoom(rooms[0].id); + const ops = room.allowedOperations; + if (Array.isArray(ops)) throw new Error('it is an array here — fix RoomItem.allowedOperations and rooms.ts'); + return `${Object.values(ops ?? {}).filter(Boolean).length} operation(s) granted`; + }); + } else { + console.log(' — no room to check allowedOperations against (in none is normal)'); + } + + // The preview route is the answer for image-only PDFs, and it has two + // undocumented enums. Only testable with a file whose preview is possible. + if (boardId) { + const skeleton = await client.getBoardSkeleton(boardId); + const cards = await client.getCards(skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId)).slice(0, 20)); + const pdfElement = cards.flatMap((c) => c.elements).filter((e) => e.type === 'file' || e.type === 'fileFolder'); + let previewed = false; + for (const element of pdfElement) { + const page = await client + .listFiles({ storageLocationId: me.school.id, parentType: 'boardnodes', parentId: element.id }) + .catch(() => undefined); + const record = page?.data.find((file) => file.previewStatus === 'preview_possible'); + if (!record) continue; + await expect(`GET /api/v3/file/preview (width=500, outputFormat=image/webp) on ${record.name}`, async () => { + const preview = await client.getFilePreview(record, 500); + return `${preview.mimeType}, ${preview.bytes.length} bytes`; + }); + previewed = true; + break; + } + if (!previewed) console.log(' — no previewable file among the sampled cards'); + } +} + +async function expect(label, run) { + try { + const detail = await run(); + console.log(` ok ${label}${detail ? ` (${detail})` : ''}`); + } catch (error) { + const status = error instanceof SchulcloudApiError ? error.status : '—'; + console.log(` FAIL ${label} → ${status} ${String(error.message).slice(0, 120)}`); + } } function summarize(result) {