Document WebUntis
docs/API.md gains the findings: the endpoint and its required version parameter, the one-time code and the two error codes worth naming, the Z that means local time, a substitution being two periods, the unused exam module that puts announced tests in the period notes, and a day without lessons that is not a holiday. Those cost an afternoon of probing to learn and nothing upstream states them. docs/AUTH.md sets the key against the Schulcloud token it sits beside: no password, nothing to keep alive, revocable on its own, and not read-only in itself — which is why the allowlist exists. PI.md and DEPLOYMENT.md add the four values, the container recreate a changed key needs, and the clock requirement; the Pi's troubleshooting table gains both failure messages. README and CLAUDE.md say what the server now is: Schulcloud for the material, WebUntis for the day. Smoke is 89 checks with the index and a key, 87 live-only, 9 fewer without one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
81
CLAUDE.md
81
CLAUDE.md
@@ -6,7 +6,9 @@ Guidance for Claude Code when working in this repository.
|
|||||||
|
|
||||||
Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software)
|
Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software)
|
||||||
account: courses, column boards, lessons, tasks, files with text extraction, and
|
account: courses, column boards, lessons, tasks, files with text extraction, and
|
||||||
a Postgres-backed full-text index. TypeScript, Node 22+,
|
a Postgres-backed full-text index — plus the **timetable from WebUntis**, which
|
||||||
|
is a separate system and the only place this school publishes when a lesson
|
||||||
|
happens, or that it was cancelled. TypeScript, Node 22+,
|
||||||
`@modelcontextprotocol/sdk`.
|
`@modelcontextprotocol/sdk`.
|
||||||
|
|
||||||
Three entry points over one core:
|
Three entry points over one core:
|
||||||
@@ -45,9 +47,10 @@ index.
|
|||||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||||
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
|
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
|
||||||
|
|
||||||
Run smoke **both ways**: with `DATABASE_URL` set (79 checks, index-backed) and
|
Run smoke **both ways**: with `DATABASE_URL` set (89 checks, index-backed) and
|
||||||
without (77 checks, live-only). The degradation path is a supported mode, not a
|
without (87 checks, live-only); without a WebUntis key both drop by 9, and the
|
||||||
fallback nobody exercises. Every Schulcloud check fails with 401 when the live
|
run then asserts the `untis_*` tools are *not* offered. The degradation paths are
|
||||||
|
supported modes, not fallbacks nobody exercises. Every Schulcloud check fails with 401 when the live
|
||||||
session has lapsed — check the container's keepalive log before suspecting code.
|
session has lapsed — check the container's keepalive log before suspecting code.
|
||||||
|
|
||||||
Store tests need a database and skip without one:
|
Store tests need a database and skip without one:
|
||||||
@@ -65,11 +68,12 @@ bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*, mcp/{resources,p
|
|||||||
│
|
│
|
||||||
bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts
|
bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts
|
||||||
|
|
||||||
services.ts (process-wide: client, Store, Indexer)
|
services.ts (process-wide: client, Store, Indexer, UntisClient)
|
||||||
│
|
│
|
||||||
indexer/indexer.ts ── store/store.ts ── Postgres
|
indexer/indexer.ts ── store/store.ts ── Postgres
|
||||||
│
|
│
|
||||||
core/{client,board,crawl,extract,text,paths,types}
|
core/{client,board,crawl,extract,text,paths,types}
|
||||||
|
core/{untis,totp,dates} ── WebUntis, a second upstream
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`core/`** knows nothing of MCP, HTTP or the CLI.
|
- **`core/`** knows nothing of MCP, HTTP or the CLI.
|
||||||
@@ -84,6 +88,16 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
|||||||
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
|
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
|
||||||
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
|
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
|
||||||
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
|
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
|
||||||
|
- **`untis.ts`** — WebUntis, the second upstream: the timetable with its
|
||||||
|
cancellations and substitutions, class-register homework and lesson topics.
|
||||||
|
Authenticates each request with a one-time code (`totp.ts`) over the key
|
||||||
|
from Profil → Freigaben, so there is no session and no keepalive on this
|
||||||
|
side. Resolves the payload's element ids to names and returns every day in
|
||||||
|
a range, empty ones included. See Invariants for why the allowlist is there.
|
||||||
|
- `dates.ts` — school days as `YYYY-MM-DD` in Europe/Berlin. The container
|
||||||
|
runs UTC, so `schoolToday()` is not `new Date()`: at 00:30 in Erfurt the
|
||||||
|
process clock still says yesterday, and a nightly briefing would prepare
|
||||||
|
the wrong day.
|
||||||
- **`http/`** — `/mcp` and `/api` take `MCP_AUTH_TOKEN`; `/mcp` alone also takes
|
- **`http/`** — `/mcp` and `/api` take `MCP_AUTH_TOKEN`; `/mcp` alone also takes
|
||||||
`MCP_CONNECTOR_TOKEN`, the request header claude.ai stores, which must never
|
`MCP_CONNECTOR_TOKEN`, the request header claude.ai stores, which must never
|
||||||
open `/api`. Besides those: the optional `/<secret>/mcp` for clients without
|
open `/api`. Besides those: the optional `/<secret>/mcp` for clients without
|
||||||
@@ -101,10 +115,15 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
|||||||
- **`mcp/tools/*.ts`** — tool descriptions are prompts: they are how Claude picks
|
- **`mcp/tools/*.ts`** — tool descriptions are prompts: they are how Claude picks
|
||||||
a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say
|
a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say
|
||||||
when *not* to use the tool.
|
when *not* to use the tool.
|
||||||
|
- **`mcp/tools/untis.ts`** — the `untis_*` tools, registered **only** when
|
||||||
|
`UNTIS_*` is configured: a tool that can only fail is worse than a missing
|
||||||
|
one. `readTimetable` is shared with the prompt, the way `readCourse` is.
|
||||||
- **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a
|
- **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a
|
||||||
person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind
|
person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind
|
||||||
`get_course`/`get_room`; and two prompts. What people read in a picker (labels,
|
`get_course`/`get_room`; and three prompts, the third being
|
||||||
prompt texts) is German; what the model reads stays English.
|
`tagesvorbereitung`, which attaches a day's timetable and is the point where
|
||||||
|
the two systems meet. What people read in a picker (labels, prompt texts) is
|
||||||
|
German; what the model reads stays English.
|
||||||
- **`context.ts`** — per-session state. Only `/me` is cached, because the school
|
- **`context.ts`** — per-session state. Only `/me` is cached, because the school
|
||||||
id is on every files-storage path and cannot change for a token.
|
id is on every files-storage path and cannot change for a token.
|
||||||
|
|
||||||
@@ -123,6 +142,15 @@ neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POS
|
|||||||
own index and mirror, and `PUT /api/token` only to the server's own token — every
|
own index and mirror, and `PUT /api/token` only to the server's own token — every
|
||||||
upstream call they make is still a GET.
|
upstream call they make is still a GET.
|
||||||
|
|
||||||
|
**WebUntis is read-only by allowlist, not by verb.** Its API is JSON-RPC, so
|
||||||
|
every call is a POST, reads included — "GET only" cannot carry over. Instead
|
||||||
|
`core/untis.ts` holds `READ_METHODS` and `assertReadMethod` refuses anything
|
||||||
|
else at the single choke point, which `test/untis.test.ts` asserts. This matters
|
||||||
|
because the key is the mobile app's credential and can do what the app can: the
|
||||||
|
live account's rights include `W_OWN_ABSENCE`, i.e. that key could report the
|
||||||
|
user absent. Add a method only after reading what it does upstream, and never
|
||||||
|
one whose name starts with `submit`, `create`, `save` or `delete`.
|
||||||
|
|
||||||
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles
|
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles
|
||||||
and filenames are all user-supplied upstream, and both the server's mirror and
|
and filenames are all user-supplied upstream, and both the server's mirror and
|
||||||
the CLI's sync turn them into filesystem paths. Everything goes through
|
the CLI's sync turn them into filesystem paths. Everything goes through
|
||||||
@@ -135,7 +163,9 @@ the property that makes that acceptable. Do not add a write tool without the
|
|||||||
user explicitly asking for one and understanding this.
|
user explicitly asking for one and understanding this.
|
||||||
|
|
||||||
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
|
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
|
||||||
account; `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` guard the endpoint. None belongs in
|
account; `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` guard the endpoint;
|
||||||
|
`UNTIS_SECRET` authenticates as the user in WebUntis and outlives every other
|
||||||
|
credential here, since it does not expire. None belongs in
|
||||||
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
|
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
|
||||||
Two more count as secrets: a token replaced at runtime (it lives only in
|
Two more count as secrets: a token replaced at runtime (it lives only in
|
||||||
`STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths** —
|
`STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths** —
|
||||||
@@ -291,6 +321,30 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
|||||||
the tab. This produced two false conclusions before being found — if a token
|
the tab. This produced two false conclusions before being found — if a token
|
||||||
dies ~2h after login, suspect an open tab first. `docs/AUTH.md` has the chain.
|
dies ~2h after login, suspect an open tab first. `docs/AUTH.md` has the chain.
|
||||||
|
|
||||||
|
WebUntis has its own set; `docs/API.md` has them all, these are the ones that
|
||||||
|
cost the most:
|
||||||
|
|
||||||
|
- **`jsonrpc_intern.do` needs `?v=i3.2`.** Without it the call fails with
|
||||||
|
`-8998` wrapping a Java NullPointerException, which reads like a malformed
|
||||||
|
body and is not. Errors also arrive with **HTTP 200** and an `error` member,
|
||||||
|
so check the body before the status.
|
||||||
|
- **The one-time code travels as a string.** One code in ten begins with a
|
||||||
|
zero, and a JSON number drops it — a login that fails 10% of the time, which
|
||||||
|
is the worst kind of failure to debug.
|
||||||
|
- **`startDateTime` ends in `Z` and is local time.** `2026-09-21T08:00Z` is the
|
||||||
|
08:00 lesson in Erfurt. Never hand these to `new Date`; `splitLocal` takes
|
||||||
|
the string apart. Every date the tools send is computed in Europe/Berlin
|
||||||
|
(`core/dates.ts`), because the container is UTC.
|
||||||
|
- **A substitution is two periods**: the original with `is: ["CANCELLED"]` and
|
||||||
|
the replacement beside it with `is: ["IRREGULAR"]`. A lesson is not "changed
|
||||||
|
in place", so both have to be read, and `orgId` on an element exists as well.
|
||||||
|
- **Announced tests live in `text.info`**, not in the exam module — this school
|
||||||
|
does not use it, so `getExams2017` is always empty. That field is the most
|
||||||
|
valuable thing in the payload.
|
||||||
|
- **A day with no lessons is not a holiday.** Vocational school weeks spent at
|
||||||
|
the company simply have no periods, and `holidays` says nothing about them.
|
||||||
|
Do not report "Ferien" for them; say there are no lessons.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
|
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
|
||||||
@@ -308,12 +362,15 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
|||||||
|
|
||||||
## Adding a tool
|
## Adding a tool
|
||||||
|
|
||||||
1. Add the client method in `core/client.ts` (`GET` only).
|
1. Add the client method in `core/client.ts` (`GET` only) — or, for the
|
||||||
|
timetable side, in `core/untis.ts`, whose method name must go in
|
||||||
|
`READ_METHODS` and must read.
|
||||||
2. Register the tool in the relevant `mcp/tools/*.ts`, with a description that
|
2. Register the tool in the relevant `mcp/tools/*.ts`, with a description that
|
||||||
says when to use it *and when not to*.
|
says when to use it *and when not to*.
|
||||||
3. Format output as Markdown, keeping ids visible for follow-up calls.
|
3. Format output as Markdown, keeping ids visible for follow-up calls.
|
||||||
4. If it reads the index, handle `context.store === undefined` with a message
|
4. If it reads the index, handle `context.store === undefined` with a message
|
||||||
saying what is unavailable and what still works.
|
saying what is unavailable and what still works. A `untis_*` tool instead
|
||||||
|
registers only when `context.untis` exists.
|
||||||
5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways.
|
5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways.
|
||||||
|
|
||||||
## Resources and prompts
|
## Resources and prompts
|
||||||
@@ -339,7 +396,9 @@ bundle (2.1.272), not its docs:
|
|||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
|
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
|
||||||
`MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`; docker-compose sets `STATE_DIR`. See `.env.example` for the
|
`MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`, plus the four `UNTIS_*` values (all
|
||||||
|
four or none — a half-filled block is a paste that went wrong, so it throws);
|
||||||
|
docker-compose sets `STATE_DIR`. See `.env.example` for the
|
||||||
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
|
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
|
||||||
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
|
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
|
||||||
hard expiry and the session budget.
|
hard expiry and the session budget.
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -1,8 +1,9 @@
|
|||||||
# schulcloud-mcp
|
# schulcloud-mcp
|
||||||
|
|
||||||
Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account —
|
Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account —
|
||||||
courses, boards, lessons, tasks and files — for **Claude**, via MCP, and for
|
courses, boards, lessons, tasks and files — plus the timetable from
|
||||||
**you**, via a CLI that mirrors your coursework to disk.
|
[WebUntis](https://www.untis.at/), 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
|
Both are front ends over one core library and one live Schulcloud session, kept
|
||||||
alive on a Pi.
|
alive on a Pi.
|
||||||
@@ -16,8 +17,9 @@ instance, not inferred from the upstream source.
|
|||||||
> *"What do I have due this week?"*
|
> *"What do I have due this week?"*
|
||||||
> *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."*
|
> *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."*
|
||||||
> *"Summarise the routing lesson from the LF10 course."*
|
> *"Summarise the routing lesson from the LF10 course."*
|
||||||
|
> *"What do I have tomorrow, and has anything been cancelled?"*
|
||||||
|
|
||||||
Twenty-four tools, all read-only:
|
Twenty-seven tools, all read-only:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -45,6 +47,9 @@ Twenty-four tools, all read-only:
|
|||||||
| `list_classes` | classes ("Klassen") with their teachers, and group membership |
|
| `list_classes` | classes ("Klassen") with their teachers, and group membership |
|
||||||
| `list_news` | school and course announcements |
|
| `list_news` | school and course announcements |
|
||||||
| `api_get` | GET-only escape hatch for uncovered API surface |
|
| `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 |
|
||||||
|
| `untis_lesson_topics` | what previous lessons of a subject actually covered ("Unterrichtsinhalt") |
|
||||||
|
|
||||||
`download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument**
|
`download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument**
|
||||||
files and returns **images inline** for Claude to look at. Image-only PDFs —
|
files and returns **images inline** for Claude to look at. Image-only PDFs —
|
||||||
@@ -56,17 +61,20 @@ MCP resource (`schulcloud://courses/<id>`, `schulcloud://rooms/<id>`) holding
|
|||||||
the same overview `get_course` and `get_room` return. In Claude Code, type `@`
|
the same overview `get_course` and `get_room` return. In Claude Code, type `@`
|
||||||
and part of the course name.
|
and part of the course name.
|
||||||
|
|
||||||
**Two ready-made prompts**, in German because the school is:
|
**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 |
|
| `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 |
|
| `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`.
|
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
|
Claude Code splits arguments on spaces and drops extra words, so join words
|
||||||
with `_` (`Lineare_Funktionen`) and skip an optional argument with `-`. *kurs*
|
with `_` (`Lineare_Funktionen`) and skip an optional argument with `-`. *kurs*
|
||||||
is any unambiguous part of a course or room name, or its id.
|
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
|
## The CLI
|
||||||
|
|
||||||
@@ -116,6 +124,19 @@ 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
|
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).
|
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).
|
||||||
|
|
||||||
**claude.ai gets a token of its own.** Its connector stores a request header,
|
**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
|
so `MCP_CONNECTOR_TOKEN` opens `/mcp` and nothing else — it is refused on
|
||||||
`/api`, which can replace the Schulcloud token — and rotates without touching
|
`/api`, which can replace the Schulcloud token — and rotates without touching
|
||||||
@@ -155,7 +176,7 @@ bypass, "what's new since…" — are sketched with their trade-offs in
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
core/ client, types, board assembly, crawler, extraction, paths
|
core/ client, types, board assembly, crawler, extraction, paths, WebUntis
|
||||||
store/ Postgres: crawl generations, diffs, full-text search
|
store/ Postgres: crawl generations, diffs, full-text search
|
||||||
indexer/ crawl → persist → mirror bytes → extract text → index
|
indexer/ crawl → persist → mirror bytes → extract text → index
|
||||||
mcp/ MCP server, tools, resources and prompts
|
mcp/ MCP server, tools, resources and prompts
|
||||||
@@ -169,8 +190,9 @@ vendor/ upstream clones, git-ignored, for reference only
|
|||||||
```
|
```
|
||||||
|
|
||||||
`core/` knows nothing about MCP, HTTP or the CLI: it holds the Schulcloud client,
|
`core/` knows nothing about MCP, HTTP or the CLI: it holds the Schulcloud client,
|
||||||
the traversal every feature needs, document extraction, and the path
|
the traversal every feature needs, document extraction, the WebUntis client with
|
||||||
sanitisation that both the server's mirror and the CLI's sync depend on.
|
its one-time codes, and the path sanitisation that both the server's mirror and
|
||||||
|
the CLI's sync depend on.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -186,7 +208,7 @@ npm run typecheck
|
|||||||
```
|
```
|
||||||
|
|
||||||
`npm run smoke` starts the HTTP server, connects a real MCP client over
|
`npm run smoke` starts the HTTP server, connects a real MCP client over
|
||||||
Streamable HTTP and exercises every tool against the live account — 77 checks (79 with the index)
|
Streamable HTTP and exercises every tool against the live account — 87 checks (89 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
|
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.
|
extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling.
|
||||||
|
|
||||||
|
|||||||
69
docs/API.md
69
docs/API.md
@@ -288,6 +288,75 @@ Two more, seen while building the local fixture:
|
|||||||
me" query reads `refPermModel`, so a share made through it never appears
|
me" query reads `refPermModel`, so a share made through it never appears
|
||||||
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly.
|
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly.
|
||||||
|
|
||||||
|
## WebUntis is a fourth store, and holds the timetable
|
||||||
|
|
||||||
|
Schulcloud's `times` on `/api/v1/courses` are empty for this school, so nothing
|
||||||
|
in Schulcloud says when a lesson happens, let alone that it was cancelled. That
|
||||||
|
lives in **WebUntis**, a separate product with its own login. What this server
|
||||||
|
uses is the API the Untis Mobile app uses, verified against
|
||||||
|
`ags-erfurt.webuntis.com` on 2026-09-17.
|
||||||
|
|
||||||
|
- **One endpoint, JSON-RPC:** `POST /WebUntis/jsonrpc_intern.do?m=<method>&school=<school>&v=i3.2`,
|
||||||
|
with `{"jsonrpc":"2.0","method":<method>,"params":[{…, "auth":{…}}]}`.
|
||||||
|
- **`v` is not optional.** Omit it and the call fails with `-8998` carrying a
|
||||||
|
Java `NullPointerException` from `getParameter`, which reads like a bug in
|
||||||
|
the request body and is not.
|
||||||
|
- Errors arrive with **HTTP 200** and an `error` member, so the body has to be
|
||||||
|
checked before the status.
|
||||||
|
- A plain `user-agent: schulcloud-mcp` is accepted; there is no need to
|
||||||
|
impersonate the app.
|
||||||
|
- **Authentication is a TOTP over a static base32 key**, no password and no
|
||||||
|
session: `auth: { user, otp, clientTime }` on every call, where `otp` is the
|
||||||
|
6-digit RFC 6238 code for the key from Profil → Freigaben → Untis Mobile.
|
||||||
|
- **Send the code as a string.** One in ten starts with a zero, which a JSON
|
||||||
|
number silently drops. Both shapes are accepted, so only the string is
|
||||||
|
always right.
|
||||||
|
- `-8504 bad credentials` = wrong key or user. `-8524 invalid client time` =
|
||||||
|
the host's clock is off, which is its own failure and worth naming.
|
||||||
|
- The response sets a `JSESSIONID`, but nothing needs it: each request
|
||||||
|
authenticates itself, which is why there is no keepalive on this side.
|
||||||
|
- **Methods that exist** (and are all this server may call — see the allowlist
|
||||||
|
in `core/untis.ts`): `getUserData2017`, `getTimetable2017`,
|
||||||
|
`getLessonTopic2017`, `getHomeWork2017`, `getMessagesOfDay2017`.
|
||||||
|
`getClassregEvents2017` and `getSchoolyears2017` answer "Method not found";
|
||||||
|
`getPeriodData2017` answers with empty objects for a student.
|
||||||
|
- **`startDateTime` claims to be UTC and is not.** Lessons come back as
|
||||||
|
`2026-09-21T08:00Z` and the school's time grid starts at 08:00 local, so the
|
||||||
|
`Z` is decoration. `new Date(...)` would move every lesson by an hour or two,
|
||||||
|
twice a year by a different amount; `splitLocal` takes the string apart
|
||||||
|
instead.
|
||||||
|
- **A substitution is two periods, not one changed period.** The original turns
|
||||||
|
up with `is: ["CANCELLED"]` and the replacement beside it with
|
||||||
|
`is: ["IRREGULAR"]`, same slot, different teacher. `orgId` on an element also
|
||||||
|
exists (the room moves that way), so both have to be read. Statuses seen on a
|
||||||
|
real account: `REGULAR`, `CANCELLED`, `IRREGULAR`.
|
||||||
|
- **`getTimetable2017` carries the whole master data with every answer** —
|
||||||
|
subjects, teachers with full names, rooms, classes, every holiday of six
|
||||||
|
school years. There is a `masterDataTimestamp` delta protocol; its removal
|
||||||
|
semantics are unverified, so this server asks for the full set and caches it
|
||||||
|
for a few hours.
|
||||||
|
- **Homework hangs off the periods too.** `getHomeWork2017` filters by the
|
||||||
|
homework's own dates, so a window ending today shows nothing due tomorrow and
|
||||||
|
nothing set last month — and the same items appear inline on the timetable's
|
||||||
|
periods, where they need no second call.
|
||||||
|
- **`getLessonTopic2017` takes `periodId`, singular.** It answers with
|
||||||
|
`previousTopics`: what the earlier lessons of that series actually covered,
|
||||||
|
from the class register. A `periodIds` array is rejected as "period 0 not
|
||||||
|
found".
|
||||||
|
- **The exam module is unused at this school**, so `getExams2017` is empty and
|
||||||
|
`period.exam` is null. Announced tests are typed into the period's **info
|
||||||
|
text** instead ("LF10: Leistungskontrolle agile Softwareentwicklung …"), which
|
||||||
|
makes `text.info` the most valuable field in the payload rather than a
|
||||||
|
footnote.
|
||||||
|
- **A day with no lessons is not necessarily a holiday.** At a vocational school
|
||||||
|
the weeks spent in the company simply have no periods, and `holidays` says
|
||||||
|
nothing about them. Reporting "Ferien" there would be wrong; so would an
|
||||||
|
empty answer.
|
||||||
|
- **The key can write.** This account's `rights` are `CLASSREGISTER`,
|
||||||
|
`R_MY_ABSENCES`, `W_OWN_ABSENCE`, `R_OFFICEHOURS` — the mobile API can report
|
||||||
|
an absence for the user. Nothing here does, and the allowlist is what
|
||||||
|
guarantees it.
|
||||||
|
|
||||||
## Re-verifying after an upstream release
|
## Re-verifying after an upstream release
|
||||||
|
|
||||||
`npm run probe` re-checks every assumption above against the live instance and
|
`npm run probe` re-checks every assumption above against the live instance and
|
||||||
|
|||||||
32
docs/AUTH.md
32
docs/AUTH.md
@@ -162,6 +162,38 @@ says so immediately:
|
|||||||
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
|
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## WebUntis: a second credential, of a different kind
|
||||||
|
|
||||||
|
The timetable is not in Schulcloud, so the server talks to WebUntis as well —
|
||||||
|
and that side authenticates far more comfortably. In WebUntis, open **Profil →
|
||||||
|
Freigaben → Untis Mobile → QR-Code**. The dialog shows four values, which go
|
||||||
|
into `.env` as `UNTIS_SERVER` (its "Url"), `UNTIS_SCHOOL`, `UNTIS_USER` and
|
||||||
|
`UNTIS_SECRET` (its "Schlüssel"). The school number shown there is not used.
|
||||||
|
|
||||||
|
Why this is the right credential for a server:
|
||||||
|
|
||||||
|
- **No password.** The key is what the Untis Mobile app is given, and it works
|
||||||
|
even where the WebUntis login goes through the school's SSO.
|
||||||
|
- **Nothing to keep alive.** Every request carries a fresh time-based code
|
||||||
|
derived from the key, so there is no session to refresh and nothing that dies
|
||||||
|
when the Pi is off for a day. Unlike the Schulcloud token, this needs no
|
||||||
|
monthly chore: the key stays valid until you replace it.
|
||||||
|
- **Revocable on its own.** Generating a new key in that dialog invalidates the
|
||||||
|
old one, and it has nothing to do with your password.
|
||||||
|
|
||||||
|
Two things to know:
|
||||||
|
|
||||||
|
- **The host's clock matters.** A time-based code from a drifting clock is
|
||||||
|
refused with `-8524 invalid client time`, which the tools report as such. Any
|
||||||
|
Pi with working NTP is fine.
|
||||||
|
- **The key is not read-only — the server is.** It can do what the app can, and
|
||||||
|
on this account that includes reporting an absence (`W_OWN_ABSENCE`). Untis'
|
||||||
|
API is JSON-RPC, where reads are POSTs too, so "GET only" cannot be the
|
||||||
|
guarantee here as it is for Schulcloud. Instead `core/untis.ts` carries an
|
||||||
|
allowlist of five read methods and refuses everything else; `test/untis.test.ts`
|
||||||
|
holds it to that. Replacing `UNTIS_SECRET` needs a restart — there is no live
|
||||||
|
swap for it, because it does not expire.
|
||||||
|
|
||||||
## Why not username + password
|
## Why not username + password
|
||||||
|
|
||||||
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri`
|
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri`
|
||||||
|
|||||||
@@ -196,6 +196,26 @@ claude mcp add --transport http --scope user schulcloud https://mcp.example.org/
|
|||||||
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
|
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## The WebUntis key
|
||||||
|
|
||||||
|
The timetable comes from WebUntis, which needs four values in `.env` —
|
||||||
|
`UNTIS_SERVER`, `UNTIS_SCHOOL`, `UNTIS_USER`, `UNTIS_SECRET` — all from WebUntis
|
||||||
|
→ Profil → Freigaben → Untis Mobile → QR-Code. With them the server offers the
|
||||||
|
`untis_*` tools and the `tagesvorbereitung` prompt; without them it does not,
|
||||||
|
and nothing else changes.
|
||||||
|
|
||||||
|
Operationally it is the easy credential: no password, it survives the container
|
||||||
|
being off, and it expires only when you generate a new key. Two consequences
|
||||||
|
for a deployment:
|
||||||
|
|
||||||
|
- **Recreate the container after changing it** (`docker compose up -d
|
||||||
|
--force-recreate schulcloud-mcp`) — `env_file` is read at creation, and unlike
|
||||||
|
the Schulcloud token there is no live swap, because there is no monthly chore
|
||||||
|
to spare anyone.
|
||||||
|
- **Keep the host's clock synchronised.** Requests are signed with a
|
||||||
|
time-based code; a drifting clock is refused with "invalid client time", which
|
||||||
|
the tools report in those words.
|
||||||
|
|
||||||
## Replacing the Schulcloud token
|
## Replacing the Schulcloud token
|
||||||
|
|
||||||
The token lasts 30 days at most and can only come from a browser login (see
|
The token lasts 30 days at most and can only come from a browser login (see
|
||||||
|
|||||||
@@ -107,9 +107,9 @@ node dist/bin/cli.js sync
|
|||||||
## Run the test suites
|
## Run the test suites
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test # 184 offline tests
|
npm test # 224 offline tests
|
||||||
npm run smoke # end-to-end against the live instance, live-only mode
|
npm run smoke # end-to-end against the live instance, live-only mode
|
||||||
DATABASE_URL=… npm run smoke # end-to-end with the index (79 checks)
|
DATABASE_URL=… npm run smoke # end-to-end with the index (89 checks with a WebUntis key)
|
||||||
```
|
```
|
||||||
|
|
||||||
Store tests need a database and skip without one:
|
Store tests need a database and skip without one:
|
||||||
|
|||||||
16
docs/PI.md
16
docs/PI.md
@@ -88,6 +88,19 @@ INDEX_PERSONAL_FILES=true
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your school publishes its timetable in **WebUntis**, add those four values
|
||||||
|
too — the timetable, its cancellations and substitutions are not in Schulcloud
|
||||||
|
at all. They are in WebUntis under Profil → Freigaben → Untis Mobile → QR-Code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat >> .env <<'EOF'
|
||||||
|
UNTIS_SERVER=yourschool.webuntis.com
|
||||||
|
UNTIS_SCHOOL=yourschool
|
||||||
|
UNTIS_USER=your.username
|
||||||
|
UNTIS_SECRET=THEKEYFROMTHEQRDIALOG
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
What those lines do:
|
What those lines do:
|
||||||
|
|
||||||
| Setting | |
|
| Setting | |
|
||||||
@@ -98,6 +111,7 @@ What those lines do:
|
|||||||
| `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. |
|
| `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. |
|
||||||
| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
|
| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
|
||||||
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
|
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
|
||||||
|
| `UNTIS_*` | WebUntis, where the school keeps the timetable. All four or none; the key needs no password and does not expire. See [AUTH.md](AUTH.md). Optional. |
|
||||||
| `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. |
|
| `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. |
|
||||||
|
|
||||||
**Copy `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` into your password manager
|
**Copy `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` into your password manager
|
||||||
@@ -415,6 +429,8 @@ Going back works the same way: set the previous commit id, then `pull` and
|
|||||||
| `unauthorized` or `pull access denied` from `docker compose pull` | Not logged in to the registry on the Pi | `docker login registry.mc02.dev` |
|
| `unauthorized` or `pull access denied` from `docker compose pull` | Not logged in to the registry on the Pi | `docker login registry.mc02.dev` |
|
||||||
| `no matching manifest for linux/arm64` | The image was published for amd64 only | Publish again with `npm run publish-image`, which builds both |
|
| `no matching manifest for linux/arm64` | The image was published for amd64 only | Publish again with `npm run publish-image`, which builds both |
|
||||||
| Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) |
|
| Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) |
|
||||||
|
| `WebUntis rejected the server's key` from a untis_* tool | The key was regenerated in WebUntis, or `UNTIS_USER` does not match it | Copy both again from Profil → Freigaben → Untis Mobile, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||||
|
| `the server's clock is too far off` from a untis_* tool | The Pi's clock has drifted; the Untis code is time-based | `timedatectl status`, then fix NTP |
|
||||||
| `EACCES` for `/data/state` or `/data/mirror` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`), `docker compose up -d` |
|
| `EACCES` for `/data/state` or `/data/mirror` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`), `docker compose up -d` |
|
||||||
|
|
||||||
## Security checklist
|
## Security checklist
|
||||||
|
|||||||
@@ -217,11 +217,13 @@ be pointed at the instance in between:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(./scripts/mcp-env.sh)" # as the demo student
|
eval "$(./scripts/mcp-env.sh)" # as the demo student
|
||||||
cd .. && npm run smoke # 79 checks against the local instance
|
cd .. && npm run smoke # 80 checks against the local instance
|
||||||
```
|
```
|
||||||
|
|
||||||
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
|
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
|
||||||
mirror at `tmp/mirror-local` — not just the instance at this one. The root
|
mirror at `tmp/mirror-local` — not just the instance at this one, and it switches
|
||||||
|
WebUntis off, since this instance has no timetable and the key in the root `.env`
|
||||||
|
is the real school's. The root
|
||||||
`.env` normally targets the live account, and process env beats `--env-file`, so
|
`.env` normally targets the live account, and process env beats `--env-file`, so
|
||||||
without that a local smoke run would crawl these fixtures into the live index,
|
without that a local smoke run would crawl these fixtures into the live index,
|
||||||
where a per-course refresh then carries them forward indefinitely. Create the
|
where a per-course refresh then carries them forward indefinitely. Create the
|
||||||
|
|||||||
Reference in New Issue
Block a user