Files
Schulcloud-MCP/README.md
MechaCat02 48c6cf39d5 Lay the notes screen out like a notes app
The list of notes and the note being written are one screen now, two
panes: the notes on the left, the open one on the right, side by side
where there is room and one at a time on a phone, where the back button
returns to the list.

The search box moved into the top of that list, and its results *are*
the list — searching is a way of finding a note, not a separate place to
be, and a tab for it was a tab too many. Emptying the box brings the
whole list back. Opening a hit opens that day at the lesson that
matched, rather than at the top of a day with six of them.

A row has to say what the note holds, so the listing carries it: the
subjects a day covers, how many lessons, and the first line actually
written in it. One request for the whole list rather than one per note.

`plainText` is now one rule in one place for wherever a note is shown
rather than edited — the search snippet and the list row both went
through their own half-copy of it, and the row's copy rendered a table
as `| | |` and left `_Fazit_` wearing its markers. It strips one leading
marker, not each in turn, because `## 1. Deutsch` keeps its lesson
number and the list rule was eating it.

Driven in Firefox at both widths: the list, the search, opening a hit,
the jump to the lesson, and the phone's list-then-note. 390 unit tests,
116/117 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:22:47 +02:00

282 lines
15 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 reads like a notes app: the notes on the left, the open one on the right, and
a search box above the list. Search goes straight to the files — no crawl in
between, so a lesson written this morning is findable this morning — and a
result names the lesson it matched in rather than the day, opening that day at
that lesson.
Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
underneath when you want it. The file on disk stays Markdown either way — that
is what the index reads and what outlives the app.
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. To add the notes
app to a server that is already running, follow
[docs/DEPLOY-NOTES.md](docs/DEPLOY-NOTES.md).
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).