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>
269 lines
14 KiB
Markdown
269 lines
14 KiB
Markdown
# schulcloud-mcp
|
|
|
|
Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account —
|
|
courses, boards, lessons, tasks and files — plus the timetable from
|
|
[WebUntis](https://www.untis.at/) and **the notes you take in class**, for
|
|
**Claude**, via MCP, and for **you**, via a CLI that mirrors your coursework to
|
|
disk.
|
|
|
|
Both are front ends over one core library and one live Schulcloud session, kept
|
|
alive on a Pi.
|
|
|
|
Built and verified against `schulcloud-thueringen.de` with a live student
|
|
account. Everything in `docs/API.md` was confirmed against the running
|
|
instance, not inferred from the upstream source.
|
|
|
|
## What Claude can do with it
|
|
|
|
> *"What do I have due this week?"*
|
|
> *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."*
|
|
> *"Summarise the routing lesson from the LF10 course."*
|
|
> *"What do I have tomorrow, and has anything been cancelled?"*
|
|
> *"Quiz me on the DIN 5008 exercise from the DK room."*
|
|
> *"What did we actually cover in Deutsch before the test — and what did I write down?"*
|
|
|
|
Thirty-one tools. Everything that touches Schulcloud and WebUntis is read-only;
|
|
`add_note` writes a file in your own notes directory and nowhere else.
|
|
|
|
| | |
|
|
|---|---|
|
|
| `whoami` | account, school, roles — also a connectivity check |
|
|
| `list_courses` | all courses, with ids |
|
|
| `get_dashboard` | the tiles as pinned on the web dashboard |
|
|
| `get_course` | one course's boards, topics and tasks |
|
|
| `get_board` | a column board in full: columns, cards, text, links, files |
|
|
| `get_lesson` | a topic's text sections, materials, files and tasks |
|
|
| `list_tasks` | homework across all courses, by due date |
|
|
| `get_task` | one task: description, due date, attachments, **and your submission** |
|
|
| `list_submissions` | what you handed in, and what is still ungraded |
|
|
| `list_files` | files attached to a board element, topic, task or submission |
|
|
| `download_file` | fetch such an attachment and extract its text, or view an image |
|
|
| `fs_list` | list a folder of the file manager — Persönliche, Kurs-, Team-, Geteilte Dateien |
|
|
| `fs_tree` | everything below a file-manager folder, as a tree |
|
|
| `fs_find` | find file-manager files and folders by name |
|
|
| `fs_read` | read a file-manager file, extracted like `download_file` |
|
|
| `search` | keyword search across everything — **including the text inside PDFs and Office files** |
|
|
| `refresh_index` | re-read Schulcloud now, per course or in full |
|
|
| `what_changed` | what appeared, changed or vanished since a date |
|
|
| `index_status` | how fresh the index is |
|
|
| `list_rooms` | rooms ("Räume"), which are a separate space from courses |
|
|
| `get_room` | one room: its boards, members and what you may do there |
|
|
| `list_classes` | classes ("Klassen") with their teachers, and group membership |
|
|
| `list_news` | school and course announcements |
|
|
| `get_h5p` | an H5P exercise in full: every question, option and correct answer |
|
|
| `api_get` | GET-only escape hatch for uncovered API surface |
|
|
| `untis_timetable` | the school day from **WebUntis**: lessons, Entfall, Vertretung, room changes, period notes |
|
|
| `untis_homework` | homework from WebUntis' class register — a separate list from Schulcloud's tasks |
|
|
| `list_notes` | your own lesson notes — what you wrote down, by subject or date |
|
|
| `get_note` | one of your notes in full |
|
|
| `add_note` | write a note down during or after a lesson |
|
|
| `untis_lesson_topics` | what previous lessons of a subject actually covered ("Unterrichtsinhalt") |
|
|
|
|
`download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument**
|
|
files and returns **images inline** for Claude to look at. Image-only PDFs —
|
|
scans with no text layer, which are common in this account — are reported as
|
|
such rather than as an empty result.
|
|
|
|
**Attach a course instead of asking for it.** Every course and room is also an
|
|
MCP resource (`schulcloud://courses/<id>`, `schulcloud://rooms/<id>`) holding
|
|
the same overview `get_course` and `get_room` return. In Claude Code, type `@`
|
|
and part of the course name.
|
|
|
|
**Your own notes are the third source.** Schulcloud has the material and
|
|
WebUntis has the schedule; neither has what the teacher actually stressed. Point
|
|
`NOTES_DIR` at a directory of Markdown files and `search`, `what_changed` and
|
|
all three prompts read it alongside everything else — including a migration path
|
|
out of Apple Notes. See [docs/NOTES.md](docs/NOTES.md).
|
|
|
|
## The notes app
|
|
|
|
A small web app at `/app`, for writing those notes: a login, the day's notes,
|
|
and a settings page for the Schulcloud token. Set `WEB_PASSWORD` to serve it.
|
|
|
|
One note per school day, one `##` heading per lesson — and **the headings come
|
|
from WebUntis**, so opening a day gives you it already laid out with times,
|
|
teachers, rooms, cancellations dropped and substitutions marked. Each heading is
|
|
indexed as its own lesson, so a search answers "my own note, Deutsch,
|
|
18.09.2026" rather than "Friday".
|
|
|
|
It saves as you type, keeps a local copy of every keystroke for when the signal
|
|
goes, and refuses a save that would overwrite a version it never saw. On a phone
|
|
it adds to the home screen and opens standalone.
|
|
|
|
**Three ready-made prompts**, in German because the school is:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `zusammenfassung` *kurs* [*fokus*] | summarise a course or room: topics, tasks, key material, each with its source |
|
|
| `pruefungsvorbereitung` *kurs* [*thema*] [*datum*] | prepare for an exam: scope, explanations, practice questions, a study plan |
|
|
| `tagesvorbereitung` [*tag*] | prepare a school day: the lessons from WebUntis, what is new in those courses, what is due |
|
|
|
|
In Claude Code they run as `/mcp__schulcloud__zusammenfassung Mathe_10b`, or
|
|
`/mcp__schulcloud__tagesvorbereitung morgen` the evening before.
|
|
Claude Code splits arguments on spaces and drops extra words, so join words
|
|
with `_` (`Lineare_Funktionen`) and skip an optional argument with `-`. *kurs*
|
|
is any unambiguous part of a course or room name, or its id; *tag* takes
|
|
`heute`, `morgen`, `übermorgen`, `21.09.2026` or `2026-09-21`.
|
|
|
|
## The CLI
|
|
|
|
```bash
|
|
schulcloud login --server https://mcp.example.org --token <token>
|
|
schulcloud sync --dry-run # see what would be mirrored
|
|
schulcloud sync # mirror coursework to ~/Schulcloud
|
|
schulcloud refresh --course <id>
|
|
schulcloud fs tree /courses # browse the file manager ("Dateien")
|
|
schulcloud fs get "/courses/<course>/<folder>"
|
|
schulcloud note ls --subject Deutsch
|
|
pbpaste | schulcloud note add --title Subnetting --subject LF07
|
|
schulcloud token set # the monthly chore: hand the Pi a fresh Schulcloud token
|
|
```
|
|
|
|
It talks only to the Pi and holds no Schulcloud credential — see
|
|
[docs/CLI.md](docs/CLI.md). A fresh token can also be pasted into the server's
|
|
`/token` page; either way the server checks it with Schulcloud and swaps it in
|
|
without a restart.
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
cp .env.example .env # fill in TSC_URL and TSC_JWT_COOKIE
|
|
npm install
|
|
npm run build
|
|
npm run probe # verifies the token and API against the live instance
|
|
```
|
|
|
|
To try it on your own machine — Docker stack, Claude Code, and the CLI — follow
|
|
[docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy and a VPS, and
|
|
connect claude.ai, follow [docs/PI.md](docs/PI.md);
|
|
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) explains the pieces.
|
|
|
|
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days —
|
|
provided you close the Schulportal window afterwards. See
|
|
[docs/AUTH.md](docs/AUTH.md); that caveat is not optional.
|
|
|
|
## Design decisions
|
|
|
|
**Bearer token plus a keepalive.** The instance's `jwt` cookie works verbatim
|
|
as `Authorization: Bearer` — no cookie jar, no `connect.sid`. Its `exp` claim
|
|
(30 days) is only a ceiling: the real limit is a 2-hour server-side session TTL
|
|
that any request slides, so the server calls `refresh-session` every 30 minutes
|
|
(the one non-GET request here, and not exposed as a tool).
|
|
|
|
The sharp edge is subtler and cost two endurance tests to find: **the cookie you
|
|
copy is the browser's own session token**, so a Schulportal tab left open will
|
|
auto-logout after ~2 hours and revoke this server's token with it. Copy the
|
|
token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md).
|
|
|
|
**The timetable comes from somewhere else.** Schulcloud holds the material for
|
|
a lesson but not the lesson: this school's course `times` are empty and it
|
|
publishes its schedule in **WebUntis**. So the server reads that too, through
|
|
the API the Untis Mobile app uses — which authenticates with a key from Profil →
|
|
Freigaben and a time-based code per request, meaning no password, no session to
|
|
hold open and nothing that expires monthly. The two systems answer different
|
|
halves of the same question, and the German `tagesvorbereitung` prompt is where
|
|
they meet: which lessons happen today, what changed in their courses, what is
|
|
due. That key *can* write (the app may report an absence), so this side is kept
|
|
read-only by an allowlist of five read methods rather than by "GET only". Unset
|
|
`UNTIS_*` and none of it exists — the tools are not even offered. See
|
|
[docs/AUTH.md](docs/AUTH.md).
|
|
|
|
**A quiz is one request, not a wizard.** Schulcloud has no quiz of its own, so
|
|
an exercise is an H5P element and the board hands over nothing but a content
|
|
id. The player then shows one question at a time, which makes a quiz look like
|
|
something to step through or scrape — it isn't: the endpoint the player loads
|
|
returns the whole exercise, every option and every solution. So `get_h5p`
|
|
prints all of it, `get_board` names it with its question count, and `search`
|
|
reaches the question text like any other material.
|
|
|
|
**claude.ai gets a token of its own.** Its connector stores a request header,
|
|
so `MCP_CONNECTOR_TOKEN` opens `/mcp` and nothing else — it is refused on
|
|
`/api`, which can replace the Schulcloud token — and rotates without touching
|
|
Claude Code or the CLI, which keep `MCP_AUTH_TOKEN`. A client that cannot send
|
|
headers can use a secret path instead (`MCP_PATH_SECRET`). See
|
|
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
|
|
|
**Read-only by construction.** Every method on the API client is a `GET`,
|
|
including `api_get`. The endpoint is internet-facing by necessity (Claude's
|
|
connectors call it from Anthropic's cloud), so the fact that a leaked token
|
|
cannot be used to *act* as the user is the main safety property. Adding one
|
|
write tool would forfeit it.
|
|
|
|
**An index, with an honest bypass.** Postgres holds crawl generations and a
|
|
`german` + `pg_trgm` full-text index over extracted file text, so search covers
|
|
the inside of PDFs rather than just their names. Every result states how fresh
|
|
the index is, and `fresh=true` bypasses it for a live read — an agent should
|
|
never be quietly misled by stale data. Without `DATABASE_URL` the server still
|
|
works: search falls back to crawling live.
|
|
|
|
**Generations, not timestamps.** Sync cursors and change detection compare crawl
|
|
generations by identity. Measured: the course-board endpoint returns *request
|
|
time* as `updatedAt`, so a timestamp cursor would report everything as changed
|
|
on every crawl — and could never detect deletions.
|
|
|
|
**Assembled, not raw.** `get_board` makes three kinds of upstream call and
|
|
stitches the results — board skeleton, card bodies, and a files-storage lookup
|
|
per file element — because a model asking "what's on this board" wants the
|
|
answer, not a traversal plan. Output is Markdown with ids preserved for
|
|
follow-up calls, not raw JSON.
|
|
|
|
Possible extensions — full-text search over file contents, a cache with a
|
|
bypass, "what's new since…" — are sketched with their trade-offs in
|
|
[docs/ROADMAP.md](docs/ROADMAP.md). None are built.
|
|
|
|
## Layout
|
|
|
|
```
|
|
src/
|
|
core/ client, types, board assembly, crawler, extraction, paths,
|
|
WebUntis and its class register, your own notes
|
|
store/ Postgres: crawl generations, diffs, full-text search
|
|
indexer/ crawl → persist → mirror bytes → extract text → index
|
|
mcp/ MCP server, tools, resources and prompts
|
|
http/ express app, bearer auth, /api for the CLI, /app for people
|
|
cli/ CLI config, API client, sync engine
|
|
bin/ http, stdio and cli entry points
|
|
docs/ API findings, auth, deployment, CLI, notes, roadmap
|
|
deploy/ Caddyfile snippet, the Pi's compose file
|
|
scripts/ probe, smoke, session diagnostics
|
|
vendor/ upstream clones, git-ignored, for reference only
|
|
```
|
|
|
|
`core/` knows nothing about MCP, HTTP or the CLI: it holds the Schulcloud client,
|
|
the traversal every feature needs, document extraction, the WebUntis client with
|
|
its one-time codes, and the path sanitisation that both the server's mirror and
|
|
the CLI's sync depend on.
|
|
|
|
## Development
|
|
|
|
```bash
|
|
npm run dev # watch mode, runs src/ directly
|
|
npm test # unit tests, no network
|
|
npm run probe # check assumptions against the live instance
|
|
npm run smoke # full end-to-end: real server, real client, real data
|
|
npm run session-diagnose # instrument what actually ends the session (~2.5h)
|
|
npm run keepalive-status # is the deployed container holding its session?
|
|
npm run publish-image # build amd64 + arm64 and push to registry.mc02.dev — the Pi only pulls
|
|
npm run typecheck
|
|
```
|
|
|
|
`npm run smoke` starts the HTTP server, connects a real MCP client over
|
|
Streamable HTTP and exercises every tool against the live account — 91 checks (93 with the index, 9 fewer without a WebUntis key)
|
|
covering the auth gate, the connector token and the secret path, the protocol handshake, every content chain, file
|
|
extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling.
|
|
|
|
## Upstream
|
|
|
|
Reference clones live in `vendor/` (git-ignored):
|
|
|
|
```bash
|
|
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/schulcloud-server.git vendor/schulcloud-server
|
|
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/file-storage.git vendor/file-storage
|
|
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/nuxt-client.git vendor/nuxt-client
|
|
```
|
|
|
|
Most of that organisation's ~100 repositories are archived or superseded; those
|
|
three are the live ones that matter. The instance's own OpenAPI documents
|
|
(`/api/v3/docs-json`, `/api/v3/file/docs-json`) are more authoritative than any
|
|
of them — see [docs/API.md](docs/API.md).
|