Files
Schulcloud-MCP/.env.example
MechaCat02 dc50b4bcd5 Write the notes in an app, a school day at a time
The notes existed but there was nowhere to write them: a CLI command on a
laptop, a tool call through Claude, or a file in a Docker volume. None of
those is reachable from a phone in a lesson, which is where notes are
actually taken.

So: `/app`, served only when WEB_PASSWORD is set. A login, the day's
notes, and a settings page for the Schulcloud token — the one surface
here meant for a person rather than a program.

The shape follows how the notes are written: one note per school day,
one `##` heading per lesson, prose and lists and tables beneath. That
turns out to be the design decision that matters, twice over.

First, it is what lets WebUntis earn its keep. Opening a day with no
note fills in that day's lessons — numbered, with times, teacher and
room, cancellations dropped and substitutions marked. Retyping the
timetable is exactly the work the second upstream exists to avoid, and
"Stunden ergänzen" tops up a note started before the day ended without
touching what is already written.

Second, it changes how notes are indexed. A day note is indexed per
lesson, not whole: search answers "my own note, Deutsch, 18.09.2026"
rather than "my own note, Friday", and `list_notes subject=Deutsch`
finds a day whose frontmatter names no subject at all. Indexed whole,
every hit would read as a weekday and "what did we do in Deutsch" would
match notes whose other five lessons were something else. `lessonHeading`
and `subjectFromHeading` are a loop — the app writes the heading, the
indexer reads the subject back out — and a test holds them to it.

Notes taken in a lesson cannot be retaken, so the editor is built
around not losing them: autosave, every keystroke mirrored to local
storage, a save when the phone locks, and a fallback to the local copy
when the request never arrives. A save that would overwrite a version
the editor never saw is refused and the choice handed back — the notes
folder is synced and open in more than one place, and a phone must not
silently win over a laptop. `replaceNote` is separate from `writeNote`
for that reason: never-overwrite is right for `add_note` and exactly
wrong for an editor.

WEB_PASSWORD is the first credential here a human types, so it is the
first that can be guessed: scrypt at startup, never stored or compared
in the clear, per-address rate limiting — which is not decoration, since
the scrypt cost is itself a denial-of-service vector without it. The
session is a signed HttpOnly SameSite=Strict cookie whose key is derived
from the password, so changing it logs everyone out and there is no
second secret to keep. It opens /api, because a session is the user, and
never /mcp, because nothing in a browser speaks MCP.

Also here, because the app made them matter: frontmatter now reads the
indented `- item` list form editors write, so an Obsidian vault
round-trips its tags; and a four-digit folder is a filing scheme, not a
subject, so `2026/` does not file a school year under one.

357 tests; 106/107 smoke against the local instance, the one failure
being the H5P service that instance does not run.

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

165 lines
8.0 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
# Password for the web app at /app — the notes editor and the settings page
# where the Schulcloud token is replaced. Unset = the app is not served at all.
#
# Unlike every other credential here this one is typed by a person on a phone,
# so it is a passphrase rather than a random token: at least 12 characters, and
# three or four words is the right shape. It is hashed with scrypt at startup
# and the plain value is never stored, compared or logged. Changing it logs out
# every session, because the session signing key is derived from it.
#
# The app is on the internet like the rest of the endpoint. Failed logins are
# rate-limited per address, but the password is the thing protecting the notes
# and the Schulcloud token — pick a long one.
# WEB_PASSWORD=
# ---------------------------------------------------------------------------
# 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