Files
Schulcloud-MCP/docker-compose.yml
MechaCat02 af4464decb Read the user's own lesson notes, and the class register behind them
Schulcloud says what was uploaded and WebUntis says what was scheduled.
Neither says what was *taught* — which point the teacher laboured, which
example landed, what "will definitely come up". That lives in two places
this server could not reach: the notes the user takes in the lesson, and
WebUntis' class register.

Notes are a directory of Markdown files (NOTES_DIR), not a table. They
have to be writable from a phone in a classroom, readable when Postgres
is down, and outlive this project, and files are the only shape that is
all three — so the files are the truth and the index is a view of them,
the same split as file_texts and the mirror. list_notes and get_note read
disk, so they answer before the first crawl; search, what_changed and all
three German prompts read them alongside the Schulcloud material.

add_note writes one, and is the only thing in this server that writes
anything. That is not a hole in the read-only invariant but a different
store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin
pair that stops a hostile Schulcloud filename escaping the mirror, so a
note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and
WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY
refuses writes outright.

Appending targets the *lesson*, not the title: "halt das auch noch fest"
mid-lesson carries a new title, and deriving the path from it would start
a second note every time, which is the one thing append exists to prevent.

Notes.app has no export — its bodies are compressed protobuf and the
iCloud copy is encrypted — so scripting the app is not the clumsy route
to the notes but the only one. scripts/export-apple-notes.js reads them
through AppleScript into one JSON object per line, and `schulcloud note
import` converts the HTML to Markdown, takes the Notes folder as the
subject and the *creation* date as the lesson's date. Attachments cannot
come across; a note that was a photo of the board imports as a line
saying so, because importing it empty would hide the loss.

The class register needed one API property to become cheap:
getLessonTopic2017 answers per *series*, not per period, so a term is
reconstructed by asking about the latest period of each lesson series and
merging back by id — a few dozen calls for a school year rather than one
per lesson. untis_lesson_topics now takes a subject as well as a period
id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind
of its own, so "what did we actually do before the test" is searchable.

Sharing the snapshot rather than duplicating it caught one thing on the
way: the search tool's live path had to learn notes too, or fresh=true
would have quietly disagreed with the index.

305 tests; 88/89 smoke against the local instance, the one failure being
the H5P service that instance does not run. The live smoke could not be
retaken: that session has lapsed and needs a fresh jwt cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:46:26 +02:00

91 lines
2.7 KiB
YAML

# The server and its Postgres.
#
# Locally, docker-compose.override.yml is merged in automatically (docs/LOCAL.md).
# On the Pi, .env selects deploy/docker-compose.pi.yml instead, which attaches
# the server to the network of the Caddy already running there (docs/PI.md).
services:
# The index's own Postgres, on a private network with the server. An existing
# instance works too — point DATABASE_URL at it and drop this service — but
# nothing requires sharing one.
postgres:
image: postgres:17-alpine
container_name: schulcloud-mcp-db
restart: unless-stopped
environment:
POSTGRES_DB: schulcloud
POSTGRES_USER: schulcloud
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-schulcloud}
volumes:
- pgdata:/var/lib/postgresql/data
# Only the server talks to Postgres. Keeping it off the Caddy network keeps
# it out of reach of whatever else shares that network on the Pi.
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U schulcloud -d schulcloud"]
interval: 10s
timeout: 5s
retries: 5
schulcloud-mcp:
build: .
image: schulcloud-mcp:latest
container_name: schulcloud-mcp
restart: unless-stopped
env_file: .env
environment:
PORT: 8080
BIND_HOST: 0.0.0.0
MIRROR_DIR: /data/mirror
STATE_DIR: /data/state
NOTES_DIR: /data/notes
depends_on:
postgres:
condition: service_healthy
volumes:
# The mirror, a replaced Schulcloud token and the user's own notes are the
# only things this server writes; everything else stays read-only, so each
# gets its own volume rather than loosening read_only.
- mirror:/data/mirror
- state:/data/state
# The notes. Swap this line for a bind mount to keep them in a folder the
# user already syncs (Syncthing, Nextcloud, an Obsidian vault) and write
# them from a phone instead of through the API — see docs/NOTES.md:
# - /home/pi/Notizen:/data/notes
- notes:/data/notes
# No ports are published to the host: Caddy reaches the container over the
# shared Docker network, so the only way in from the internet is through
# Caddy's TLS and this server's bearer check.
expose:
- "8080"
networks:
- caddy
- backend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
volumes:
pgdata:
mirror:
state:
notes:
networks:
backend:
caddy:
# On the Pi, deploy/docker-compose.pi.yml makes this the network your
# existing Caddy already uses (see docs/PI.md).
external: false
name: caddy