Files
Schulcloud-MCP/.env.example
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

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