`console.log` in JXA writes to standard error, not standard output, so `> notes.ndjson` produced an empty file while every note scrolled past on the terminal. Both streams now go through NSFileHandle, which is the only way to be sure which one you are on; the comment says so, because the next person will reach for console.log too. `--folder` was a substring match applied after each note's body had already been read. It now matches a whole path segment — `Schule` takes Schule and everything under it and leaves Musikschule alone — and is checked before the body, which is the one expensive property and is what makes a large library take minutes. The path is recorded relative to the folder asked for, because the import reads its last segment as the subject: `Schule/Deutsch` has to arrive as `Deutsch`, and a note loose in `Schule` has to arrive with no subject at all rather than one called "Schule". Not run: this needs a Mac with Notes, and there is none here. The JXA stream behaviour is the documented one and the folder logic is plain string work, but the script itself is still unexercised.
12 KiB
Rolling out the notes app
A runbook for putting the notes feature and the /app web app onto a
deployment that is already running. PI.md is the first-time setup;
this is the upgrade, and it assumes the server is live and healthy.
Read NOTES.md first if you have not: step 2 is a decision you should not make twice, because changing it later means moving files by hand.
What arrives
| Your own lesson notes | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: list_notes, get_note, add_note. |
The app at /app |
A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when WEB_PASSWORD is set. |
| The WebUntis class register | untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of "what was actually taught" goes into the search index. |
Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes directory is the only thing this server writes to, and it is yours.
Before you start
- Ten minutes, plus however long a full crawl takes (the first one with a class register adds a few dozen WebUntis requests; it runs in the background).
- No Caddy change.
/appis served by the same container on the same port, and the snippet already proxies everything. One line was added todeploy/Caddyfile.snippetas a comment aboutX-Forwarded-Proto; if your site block predates it, check that nothing strips that header — the session cookie'sSecureflag depends on it. - No database migration. Notes and class-register entries are new node kinds
in a column that is plain
TEXT. - No compose change on the Pi.
deploy/docker-compose.pi.ymloverrides only the image, the database URL and the network, so thenotesvolume andNOTES_DIR=/data/notescome fromdocker-compose.ymlunchanged.
1. Publish the image
On a development machine, from a clean checkout of main:
npm run publish-image
It builds arm64 and amd64 and pushes latest plus the commit id. If you pin
SCHULCLOUD_MCP_TAG, note the short commit it prints — you need it in step 3.
2. Decide where the notes live
Do this before anything writes a note. Both options work; moving between them afterwards means moving files.
A — the volume (default, nothing to do). docker-compose.yml already
declares a notes volume at /data/notes. The app and the CLI write to it,
add_note writes to it, and that is the whole story. Choose this if you will
write notes in the app and nowhere else.
B — a directory you sync. Choose this to also write notes from a phone or
a laptop in an editor — Obsidian, iA Writer, a git repo. Edit
docker-compose.yml on the Pi:
# under schulcloud-mcp:
volumes:
- mirror:/data/mirror
- state:/data/state
- /home/pi/Notizen:/data/notes # was: notes:/data/notes
mkdir -p /home/pi/Notizen
The container runs unprivileged and read_only, so that directory must be
writable by the container's user — if the logs show EACCES for /data/notes,
see Troubleshooting. Then point Syncthing, Nextcloud or git at it. The server
does not care which; new files are picked up by the next full crawl.
3. Configure
On the Pi, in /opt/schulcloud-mcp:
cd /opt/schulcloud-mcp
git pull
Add the app password to .env. It is the only credential here a person types,
so it is a passphrase rather than a token — at least 12 characters, and three
or four words is the right shape:
cat >> .env <<'EOF'
# --- the notes app ---
WEB_PASSWORD=change-this-to-three-or-four-words
EOF
Optional, on the same pass:
| Setting | |
|---|---|
UNTIS_HISTORY_DAYS |
How far back to index the class register. Default 180; 0 turns it off. Only does anything with UNTIS_* configured. |
NOTES_READONLY=1 |
Refuse every write. list_notes and get_note still work, add_note and the app's save do not. Right when the notes are synced in and should have exactly one writer. |
SCHULCLOUD_MCP_TAG |
If you pin images, set it to the commit from step 1. |
Put WEB_PASSWORD in your password manager now. The server never prints it,
and changing it logs out every session.
4. Start it
docker compose pull
docker compose up -d
docker compose logs --tail 20 schulcloud-mcp
The startup line names what is on. With everything configured it ends
… keepalive every 30min, index every 6h; the app and the notes are not named
there, so verify them in the next step rather than reading the log for them.
5. Verify
From anywhere:
curl -s https://mcp.example.org/healthz
# {"status":"ok","sessions":0,"index":"on"}
curl -s -o /dev/null -w '%{http_code}\n' https://mcp.example.org/app/
# 200 ← the app is served; 404 means WEB_PASSWORD is not set
Then open https://mcp.example.org/app/ in a browser and log in.
- Notizen should show today, and — if WebUntis is configured — today's lessons as headings, with times, teacher and room. "Kein Unterricht an diesem Tag" on a company-phase week or a weekend is correct, not a fault.
- Type a line and wait two seconds. The status line should read Gespeichert HH:MM.
- Einstellungen should show the Schulcloud token's remaining days and the index's state.
On a phone, add it to the home screen — it has a manifest and opens standalone.
Check the file landed where you meant it to:
docker compose exec schulcloud-mcp ls -R /data/notes
# 2026/2026-09-19.md
6. Bring the old notes in
If you have notes in Apple Notes, migrate them now — see NOTES.md. Briefly, on the Mac:
# --folder takes that folder and everything under it; leave it off for all notes
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > notes.ndjson
wc -l notes.ndjson # one line per note
schulcloud note import notes.ndjson --dry-run # look first
schulcloud note import notes.ndjson
The export runs on the Mac; the import does not have to. The CLI talks to the
server over HTTP, so copying the .ndjson to whatever machine already has the
CLI configured is the shorter path.
Import once. Re-running creates second copies, because the importer cannot tell an edited note from a new one with the same title.
7. Index them
Notes and the class register are only read by a full crawl. One runs on the
timer (CRAWL_INTERVAL_MS, six hours by default), or force one now:
schulcloud refresh --force
Expect it to take minutes; the CLI polls and prints progress. When it finishes:
schulcloud status
Then ask Claude something only the new sources can answer — "what did I write down in Deutsch last week?" or "what did we actually cover in LF07 this term?" — and check the answer names your note or the class register as its source.
Backups, which now matter more
The notes are the only irreplaceable thing this server holds. Everything else it stores is a copy of something upstream; a note you took in a lesson is not, and nothing can rebuild it.
| What | Needed? |
|---|---|
.env |
Yes — every secret, WEB_PASSWORD included. Encrypted only. |
schulcloud-mcp_notes (option A) |
Yes. Nothing can regenerate these. |
| Your synced directory (option B) | Yes, unless the sync tool already keeps versioned copies elsewhere — and check that it does, rather than assuming. |
| Postgres | Optional: a crawl rebuilds it. |
schulcloud-mcp_mirror |
No — re-downloaded by the next crawl. |
schulcloud-mcp_state |
No — a replaced token, expiring within 30 days anyway. |
With the volume (option A):
docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \
tar czf /out/notizen-$(date +%F).tar.gz -C /notes .
They are small — a school year of notes is a few megabytes — so back them up often and keep the old copies.
Rolling back
The feature adds no migration and no incompatible state, so going back is the ordinary downgrade:
# in .env
SCHULCLOUD_MCP_TAG=<previous commit>
docker compose pull && docker compose up -d
The old image ignores WEB_PASSWORD and NOTES_DIR and serves no /app. The
notes volume is untouched — the files stay, and the newer image picks them up
again unchanged. The only thing lost while rolled back is the ability to read or
write them.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
/app/ answers 404 |
WEB_PASSWORD not set, or the container not recreated since it was |
grep ^WEB_PASSWORD= .env, then docker compose up -d --force-recreate schulcloud-mcp |
The server refuses to start, log names WEB_PASSWORD |
Shorter than 12 characters | Use a longer passphrase |
| Login says the password is wrong, and it is not | .env is read at container creation |
docker compose up -d --force-recreate schulcloud-mcp |
| Logged out constantly, or the login "does nothing" | The session cookie is marked Secure and the connection is not HTTPS, or X-Forwarded-Proto is stripped |
Reach it over HTTPS; check the Caddy site does not strip that header |
Zu viele Fehlversuche |
The per-address rate limiter, eight failures in fifteen minutes | Wait it out; it is doing its job |
| The editor says the server keeps no notes | NOTES_DIR unset on the server |
It is set by docker-compose.yml; check COMPOSE_FILE in .env still lists it first |
Der Server nimmt keine Änderungen an |
NOTES_READONLY is on |
Remove it and recreate the container |
| Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app |
EACCES for /data/notes in the logs |
A bind-mounted directory the container's user cannot write | sudo chown -R 1000:1000 /home/pi/Notizen (match the image's user), then recreate |
| The toolbar is there, the text stays plain | The browser blocked editor.js or markdown.js |
Check the console; both must be served from /app/, and app.js must load as type="module" |
| A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note |
Notes exist but search cannot find them |
Only a full crawl reads them | schulcloud refresh --force |
search finds a day note but names no subject |
The lesson headings were rewritten past recognition | Keep ## 1. Deutsch …; the leading number and the subject are what the index reads |
untis_lesson_topics with a subject finds nothing |
The subject code differs from what you typed | Check it against untis_timetable; the register uses the school's own codes |
Security notes for this rollout
WEB_PASSWORDis the first credential here that a human types, so the first that can be guessed. It is hashed with scrypt at startup and never stored, compared or logged in the clear, and failed logins are rate-limited per address — but length is what actually protects it.- The session cookie opens
/api, which can read your coursework and replace the Schulcloud token. It does not open/mcp. Treat a login on a shared device as you would treat the token. - Changing
WEB_PASSWORDinvalidates every session, because the signing key is derived from it. That is the revocation mechanism: change it, recreate the container, log in again. - The write surface is bounded to
NOTES_DIRbysafeComponentandresolveWithin— the same two functions that stop a hostile Schulcloud filename escaping the file mirror. Schulcloud and WebUntis remain read-only.