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>
151 lines
7.3 KiB
Plaintext
151 lines
7.3 KiB
Plaintext
# ---------------------------------------------------------------------------
|
|
# Schulcloud instance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Base URL of the instance, no trailing slash.
|
|
TSC_URL=https://schulcloud-thueringen.de
|
|
|
|
# The value of the `jwt` cookie from a logged-in browser session.
|
|
# The 30-day `exp` is only a ceiling; the real limit is a 2-hour sliding session
|
|
# TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal
|
|
# window after copying this — an open tab shares the session and its auto-logout
|
|
# will revoke this token ~2h after login. See docs/AUTH.md.
|
|
# Needed for the first start. Later tokens go in with `schulcloud token set` or
|
|
# the server's /token page, without a restart; a saved newer one wins over this.
|
|
TSC_JWT_COOKIE=
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# This MCP server
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Shared secret callers must present as `Authorization: Bearer <token>`.
|
|
# REQUIRED for the public deployment — without it the endpoint is open to
|
|
# anyone who finds the hostname. Generate one with:
|
|
# openssl rand -hex 32
|
|
MCP_AUTH_TOKEN=
|
|
|
|
# claude.ai: the token its connector sends as a request header
|
|
# (`authorization: Bearer <token>`). Accepted on /mcp only, never on /api — which
|
|
# can replace the Schulcloud token — because claude.ai stores it. At least 32
|
|
# characters, different from MCP_AUTH_TOKEN; unset = off. Generate one with:
|
|
# openssl rand -hex 32
|
|
# MCP_CONNECTOR_TOKEN=
|
|
|
|
# Clients that cannot send a header: serve MCP at /<this value>/mcp with no
|
|
# token at all. The URL becomes the credential — see "Connecting Claude" in
|
|
# docs/DEPLOYMENT.md before using it. At least 32 URL-safe characters; unset =
|
|
# off. Generate one with:
|
|
# openssl rand -hex 32
|
|
# MCP_PATH_SECRET=
|
|
|
|
# Where a Schulcloud token replaced at runtime (`schulcloud token set`, /token)
|
|
# is saved, so a restart keeps it. docker-compose.yml sets /data/state; unset =
|
|
# replacements last until the next restart.
|
|
# STATE_DIR=/data/state
|
|
|
|
# Listen address inside the container. Leave as-is when running behind Caddy.
|
|
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)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Postgres for crawl generations, full-text search and the file mirror index.
|
|
# On the Pi, point this at the existing instance with its own database and user.
|
|
DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
|
|
|
|
# Where mirrored file bytes are stored. Needs to be writable by the container.
|
|
# MIRROR_DIR=/data/mirror
|
|
|
|
# Files larger than this are indexed as metadata but not mirrored; they are
|
|
# still downloadable, proxied live. Default 64 MiB.
|
|
# MIRROR_MAX_BYTES=67108864
|
|
|
|
# Also index personal files ("Meine Dateien") and submitted / returned work,
|
|
# including teacher grade comments. This is what makes "what did the teacher
|
|
# say about X" searchable and lets what_changed report a re-grade. Costs roughly
|
|
# three extra requests per task on a full crawl, so it is off by default.
|
|
# INDEX_PERSONAL_FILES=false
|
|
|
|
# Also walk the file manager ("Dateien") when crawling: Kurs-Dateien for every
|
|
# course, plus Persönliche, Team- and Geteilte Dateien on a full crawl. Many
|
|
# teachers keep their material only there, so this is on by default. One page
|
|
# load per folder — about 160 on a 26-course account.
|
|
# INDEX_FILE_MANAGER=true
|
|
|
|
# How often to re-crawl on a timer, in ms. Default 21600000 (6h). 0 = on demand
|
|
# only. A re-crawl of unchanged content downloads nothing, because Schulcloud
|
|
# file records are immutable.
|
|
# CRAWL_INTERVAL_MS=21600000
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Your own lesson notes (optional — what you wrote down in class)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Directory of Markdown files holding your own notes. With it set, the server
|
|
# offers list_notes, get_note and add_note, indexes the notes so `search` finds
|
|
# them, and the German prompts consult them. Unset = none of that exists.
|
|
# docker-compose.yml sets /data/notes and gives it a volume; bind-mount a synced
|
|
# folder there instead to write notes from a phone. See docs/NOTES.md.
|
|
# NOTES_DIR=/data/notes
|
|
|
|
# Leave the files alone: list_notes and get_note still work, add_note and
|
|
# POST /api/notes are refused. Right for a deployment whose notes are synced in
|
|
# from somewhere else and should have exactly one writer.
|
|
# NOTES_READONLY=false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebUntis (optional — the timetable, which Schulcloud does not hold)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Where the school publishes its timetable. With these set, the server offers
|
|
# untis_timetable, untis_homework and untis_lesson_topics, plus the German
|
|
# "tagesvorbereitung" prompt; without them none of that exists. All four values
|
|
# are in one dialog: WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.
|
|
#
|
|
# UNTIS_SECRET is the "Schlüssel" field and is a credential: it authenticates
|
|
# every request as this user, needs no password, works with an SSO login, and
|
|
# stays valid until you generate a new key in that dialog. It can do whatever
|
|
# the Untis Mobile app can — this server only ever calls read methods, by
|
|
# allowlist (src/core/untis.ts). See docs/AUTH.md.
|
|
#
|
|
# Authentication is a time-based code, so the host's clock must be in sync;
|
|
# WebUntis answers -8524 ("invalid client time") when it is not.
|
|
# UNTIS_SERVER=yourschool.webuntis.com
|
|
# UNTIS_SCHOOL=yourschool
|
|
# UNTIS_USER=your.username
|
|
# UNTIS_SECRET=
|
|
|
|
# How far back to read the class register ("Unterrichtsinhalt", plus the notes
|
|
# teachers leave on a period) into the search index, in days. Default 180; 0
|
|
# turns it off. Costs one timetable call per 90 days plus one per lesson series
|
|
# on a full crawl — a few dozen requests for a school year. This is what makes
|
|
# "what did we actually do before the test" searchable rather than something to
|
|
# reconstruct one tool call at a time.
|
|
# UNTIS_HISTORY_DAYS=180
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Limits (optional — sensible defaults are built in)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Largest file download_file will pull, in bytes. Default 25 MiB.
|
|
# Videos in Schulcloud routinely exceed this; they are not extractable anyway.
|
|
# MAX_DOWNLOAD_BYTES=26214400
|
|
|
|
# Characters of extracted text returned before truncation. Default 120000.
|
|
# MAX_EXTRACTED_CHARS=120000
|
|
|
|
# Per-request timeout against the Schulcloud API, in ms. Default 30000.
|
|
# REQUEST_TIMEOUT_MS=30000
|
|
|
|
# How often to call refresh-session to hold the session open, in ms. Default
|
|
# 1800000 (30 min). Must stay well under the instance's JWT_TIMEOUT_SECONDS —
|
|
# 7200s here, readable from GET /api/v3/config/public. Set to 0 to disable.
|
|
# KEEPALIVE_INTERVAL_MS=1800000
|