Read the timetable from WebUntis

Schulcloud holds the material for a lesson but not the lesson: this school's
course `times` are empty and it publishes the schedule in WebUntis. So "what
do I have today, and has anything been cancelled" was unanswerable, and the
timetable cannot be typed into a prompt either — it changes daily.

core/untis.ts talks to the API the Untis Mobile app uses, and three tools sit
on it: untis_timetable (a day or a range, Entfall, Vertretung, room changes,
the notes on each period, inline homework, the period id), untis_homework (the
class register's list, which is not Schulcloud's tasks) and
untis_lesson_topics (what earlier lessons of a series actually covered, which
is what says where a subject got to).

Read-only, but not by the Schulcloud client's rule: this API is JSON-RPC, so
every call is a POST, reads included. READ_METHODS is the guarantee instead,
enforced at the single choke point and asserted by a test. It matters because
the key can do what the app can — the live account holds W_OWN_ABSENCE, so
the same key could report the user absent.

What the live instance taught us, all recorded in docs/API.md:

- `startDateTime` ends in Z and is local time. The 08:00 lesson reports
  08:00Z, so new Date() would move every lesson by an hour or two.
- A substitution is two periods, the original CANCELLED and the replacement
  IRREGULAR beside it, not one period with a changed teacher.
- Announced tests live in the period's info text. The exam module is unused
  here, so getExams2017 is always empty and that field carries the tests.
- A day with no lessons is not a holiday: the weeks this account spends in the
  company simply have no periods.
- `?v=i3.2` is required, or the call fails with a Java NPE reported as -8998.
  Errors arrive with HTTP 200 and an error member. -8504 is a rejected key and
  -8524 a drifting clock; the tools name both, because no retry fixes either.

Configuration is all four UNTIS_* values or none — three are identifiers and
the fourth is a credential, so a half-filled block is a paste that went wrong.
Without them the tools are not registered at all, since a tool that can only
fail is worse than a missing one. whoami reports the WebUntis identity and
survives a dead Schulcloud session, so "is the server reachable" no longer
gets a misleadingly total no. mcp-env.sh switches WebUntis off for a fixture
run: that key belongs to the real school.

224 tests. All 10 WebUntis smoke checks pass, with a key and without one; the
Schulcloud checks in those runs answer 401 because this machine's session is
logged out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-17 20:43:24 +02:00
parent 87c5cedf3c
commit 74b97bc4dc
13 changed files with 1575 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ import { registerIndexTools } from './tools/index-tools.ts';
import { registerSearchTool } from './tools/search.ts';
import { registerRoomTools } from './tools/rooms.ts';
import { registerSubmissionTools } from './tools/submissions.ts';
import { registerUntisTools } from './tools/untis.ts';
export const SERVER_NAME = 'schulcloud-mcp';
export const SERVER_VERSION = '0.1.0';
@@ -43,6 +44,14 @@ How the content is organised, and the usual path through it:
graded submission, say it was not found rather than that none was given. On a teacher account these
tools report other people's submissions too.
**The timetable is not in Schulcloud.** When the untis_* tools are listed, the school's schedule lives in
WebUntis and they are the only way to it: untis_timetable says which lessons a day actually holds, what was
cancelled ("Entfall"), what is a substitution ("Vertretung") and what a teacher noted on a period — announced
tests are usually in those notes. Schulcloud holds the material for those lessons, so the two go together:
take the subject from untis_timetable, then find its course with list_courses. untis_homework is the class
register's homework, which is a different list from Schulcloud's tasks; check both. untis_lesson_topics says
what previous lessons of a subject actually covered.
When the user names a topic rather than a course, use search — the API has no search endpoint, so it walks the
courses and matches client-side, which takes a few seconds but covers board text and file names.
@@ -66,6 +75,8 @@ export function createServer(config: Config, services?: Services): { server: Mcp
registerSearchTool(server, context);
registerSubmissionTools(server, context);
registerIndexTools(server, context);
// Only when a key is configured: the tools are not offered at all otherwise.
registerUntisTools(server, context);
registerRawTool(server, context);
registerResources(server, context);
registerPrompts(server, context);