From 74b97bc4dcd18805ffa50a113da33828b034715d Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 17 Sep 2026 20:43:24 +0200 Subject: [PATCH] Read the timetable from WebUntis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 22 ++ local-instance/scripts/mcp-env.sh | 7 + scripts/smoke.mjs | 85 +++++ src/bin/http.ts | 5 +- src/config.ts | 49 +++ src/context.ts | 12 +- src/core/untis.ts | 555 ++++++++++++++++++++++++++++++ src/mcp/server.ts | 11 + src/mcp/tools/overview.ts | 30 +- src/mcp/tools/untis.ts | 335 ++++++++++++++++++ src/services.ts | 10 +- test/config.test.ts | 52 +++ test/untis.test.ts | 406 ++++++++++++++++++++++ 13 files changed, 1575 insertions(+), 4 deletions(-) create mode 100644 src/core/untis.ts create mode 100644 src/mcp/tools/untis.ts create mode 100644 test/untis.test.ts diff --git a/.env.example b/.env.example index 4bdb175..a6fd66c 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,28 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud # file records are immutable. # CRAWL_INTERVAL_MS=21600000 +# --------------------------------------------------------------------------- +# WebUntis (optional — the timetable, which Schulcloud does not hold) +# --------------------------------------------------------------------------- + +# Where the school publishes its timetable. With these set, the server offers +# untis_timetable, untis_homework and untis_lesson_topics, plus the German +# "tagesvorbereitung" prompt; without them none of that exists. All four values +# are in one dialog: WebUntis → Profil → Freigaben → Untis Mobile → QR-Code. +# +# UNTIS_SECRET is the "Schlüssel" field and is a credential: it authenticates +# every request as this user, needs no password, works with an SSO login, and +# stays valid until you generate a new key in that dialog. It can do whatever +# the Untis Mobile app can — this server only ever calls read methods, by +# allowlist (src/core/untis.ts). See docs/AUTH.md. +# +# Authentication is a time-based code, so the host's clock must be in sync; +# WebUntis answers -8524 ("invalid client time") when it is not. +# UNTIS_SERVER=yourschool.webuntis.com +# UNTIS_SCHOOL=yourschool +# UNTIS_USER=your.username +# UNTIS_SECRET= + # --------------------------------------------------------------------------- # Limits (optional — sensible defaults are built in) # --------------------------------------------------------------------------- diff --git a/local-instance/scripts/mcp-env.sh b/local-instance/scripts/mcp-env.sh index 323d057..90097b4 100755 --- a/local-instance/scripts/mcp-env.sh +++ b/local-instance/scripts/mcp-env.sh @@ -38,4 +38,11 @@ export MCP_AUTH_TOKEN=local-instance-token export DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_local export MIRROR_DIR=$ROOT/tmp/mirror-local export INDEX_PERSONAL_FILES=true +# WebUntis off for a local run: this instance has no timetable, and the key in +# the repo's .env belongs to the real school — a fixture run has no business +# talking to it, even read-only. +export UNTIS_SERVER= +export UNTIS_SCHOOL= +export UNTIS_USER= +export UNTIS_SECRET= ENV diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 29b5b96..d0daf33 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -466,6 +466,91 @@ if (hasIndex) { ); } +console.log('\n== WebUntis =='); +// The timetable lives in WebUntis, not in Schulcloud, and the tools only exist +// when a key is configured. Both halves are asserted: with a key the live +// answers have to be shaped right, without one the tools must not be offered at +// all — a tool that can only fail is worse than a missing one. +const hasUntis = Boolean(config.untis); +{ + const untisTools = names.filter((name) => name.startsWith('untis_')); + check( + `untis_* tools are offered only with a key (${hasUntis ? 'configured' : 'not configured'})`, + hasUntis + ? untisTools.join(' ') === 'untis_homework untis_lesson_topics untis_timetable' + : untisTools.length === 0, + untisTools.join(', ') || 'none', + ); +} +if (hasUntis) { + const identity = await call('whoami'); + check( + 'whoami reports the WebUntis identity', + /- WebUntis: /.test(identity.text) && !/not reachable/.test(identity.text), + identity.text.split('\n').find((line) => line.startsWith('- WebUntis')), + ); + + const today = await call('untis_timetable'); + check( + 'untis_timetable answers for today', + !today.isError && /^## \p{L}+, \d{2}\.\d{2}\.\d{4}/mu.test(today.text), + today.text.split('\n')[0], + ); + + // A four-week window: either it holds lessons or the days say why not. The + // school year has gaps — holidays, and the weeks this account spends at work — + // so requiring lessons would make the run fail on a correct answer. + const start = new Date().toISOString().slice(0, 10); + const end = new Date(Date.now() + 28 * 86_400_000).toISOString().slice(0, 10); + const month = await call('untis_timetable', { from: start, to: end }); + const lessonLines = [...month.text.matchAll(/^- \d{2}:\d{2}–\d{2}:\d{2} /gm)].length; + check( + 'untis_timetable answers for a four-week range', + !month.isError && (lessonLines > 0 || /No lessons/.test(month.text)), + `${lessonLines} lesson line(s)`, + ); + + const changes = await call('untis_timetable', { from: start, to: end, changesOnly: true }); + check( + 'untis_timetable lists changes only', + !changes.isError && (/\*\*(Entfall|Vertretung)\*\*/.test(changes.text) || /Nothing cancelled or changed/.test(changes.text)), + changes.text.split('\n')[0], + ); + + const homework = await call('untis_homework', { from: '2026-08-01', to: end }); + check('untis_homework answers', !homework.isError, homework.text.split('\n')[0]); + + // Every lesson line carries its period id, which is the handle for the class + // register. Without one there is nothing to ask about, so the check follows + // the timetable's own output rather than a hard-coded id. + const periodId = Number(month.text.match(/`(\d{5,})`/)?.[1]); + if (Number.isInteger(periodId)) { + const topics = await call('untis_lesson_topics', { periodId, limit: 3 }); + check( + 'untis_lesson_topics reads what previous lessons covered', + !topics.isError && (/Unterrichtsinhalte/.test(topics.text) || /No lesson contents/.test(topics.text)), + topics.text.split('\n')[0], + ); + } else { + check('untis_lesson_topics reads what previous lessons covered', true, 'skipped: no lesson in the window'); + } + + const unreal = await call('untis_timetable', { from: '2026-02-30' }); + check( + 'a date that does not exist is refused rather than rolled over', + unreal.isError && /Not a date in the calendar/.test(unreal.text), + unreal.text.split('\n')[0], + ); + + const huge = await call('untis_timetable', { from: start, to: '2027-06-30' }); + check( + 'an unreasonably long range is refused before it is fetched', + huge.isError && /at most \d+ at a time/.test(huge.text), + huge.text.split('\n')[0], + ); + +} + console.log('\n== api_get guard rails =='); check('api_get allows /api/ paths', !(await call('api_get', { path: '/api/v3/me' })).isError); check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError); diff --git a/src/bin/http.ts b/src/bin/http.ts index d1e5d4a..862e273 100644 --- a/src/bin/http.ts +++ b/src/bin/http.ts @@ -75,7 +75,10 @@ async function main(): Promise { `token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` + `${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` + `keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` + - `index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`, + `index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}, ` + + // The origin, never the key: this is the line that says whether the + // timetable tools exist at all in this deployment. + `untis ${services.untis ? services.untis.origin : 'off'}`, ); }); diff --git a/src/config.ts b/src/config.ts index e57ac93..b60f387 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ */ import { resolve } from 'node:path'; +import type { UntisConfig } from './core/untis.ts'; export interface Config { /** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */ @@ -61,6 +62,13 @@ export interface Config { indexFileManager: boolean; /** How often to re-crawl on a timer. Zero = only on demand. */ crawlIntervalMs: number; + + /** + * WebUntis, where the school keeps the timetable. Unset = the untis_* tools + * are not offered at all, which is the right answer for a school that does + * not use it — Schulcloud alone cannot say what happens when. + */ + untis: UntisConfig | undefined; } function required(name: string): string { @@ -114,6 +122,46 @@ function secretToken(name: string): string | undefined { return value; } +/** + * The four WebUntis values, or undefined when none is set. + * + * All four or nothing: three of them are harmless identifiers and the fourth is + * a credential, so a half-filled block is a copy-paste that went wrong, not a + * configuration to guess at. They come from one dialog — WebUntis → Profil → + * Freigaben → Untis Mobile → QR-Code — and the error says so, because that is + * the only place to find them. + */ +function untisConfig(): UntisConfig | undefined { + const server = process.env.UNTIS_SERVER?.trim(); + const school = process.env.UNTIS_SCHOOL?.trim(); + const user = process.env.UNTIS_USER?.trim(); + const secret = process.env.UNTIS_SECRET?.trim(); + const missing = Object.entries({ UNTIS_SERVER: server, UNTIS_SCHOOL: school, UNTIS_USER: user, UNTIS_SECRET: secret }) + .filter(([, value]) => !value) + .map(([name]) => name); + if (missing.length === 4) return undefined; + if (!server || !school || !user || !secret) { + throw new Error( + `WebUntis needs all four of UNTIS_SERVER, UNTIS_SCHOOL, UNTIS_USER and UNTIS_SECRET — missing: ` + + `${missing.join(', ')}. All four are in WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.`, + ); + } + if (!/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(server)) { + throw new Error( + `UNTIS_SERVER must be the bare host from the QR dialog's "Url" field, e.g. "ags-erfurt.webuntis.com" — ` + + `no scheme and no path, got "${server}".`, + ); + } + // A credential: the error states the rule and never echoes the value. + if (!/^[A-Za-z2-7]{8,}$/.test(secret)) { + throw new Error( + 'UNTIS_SECRET must be the key from the Untis Mobile QR dialog: at least 8 characters of A-Z and 2-7 ' + + '(base32), no spaces.', + ); + } + return { server, school, user, secret }; +} + /** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */ function intAllowingZero(name: string, fallback: number): number { const raw = process.env[name]?.trim(); @@ -162,5 +210,6 @@ export function loadConfig(): Config { // so an index without it misses whole courses. One page load per folder. indexFileManager: bool('INDEX_FILE_MANAGER', true), crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000), + untis: untisConfig(), }; } diff --git a/src/context.ts b/src/context.ts index 43454d6..e1c0b76 100644 --- a/src/context.ts +++ b/src/context.ts @@ -2,6 +2,7 @@ import type { Config } from './config.ts'; import { SchulcloudClient } from './core/client.ts'; import { FileManager } from './core/legacy-files.ts'; import type { LegacyUser, MeResponse } from './core/types.ts'; +import { UntisClient } from './core/untis.ts'; import type { Indexer } from './indexer/indexer.ts'; import type { Store } from './store/store.ts'; @@ -20,6 +21,8 @@ export class ServerContext { /** Shared across sessions; undefined when running without an index. */ readonly store: Store | undefined; readonly indexer: Indexer | undefined; + /** WebUntis — the timetable — or undefined when the server has no key for it. */ + readonly untis: UntisClient | undefined; private identity: Promise | undefined; /** * id -> display name, for the whole session. @@ -35,13 +38,20 @@ export class ServerContext { constructor( config: Config, - shared?: { client?: SchulcloudClient; files?: FileManager; store?: Store; indexer?: Indexer }, + shared?: { + client?: SchulcloudClient; + files?: FileManager; + store?: Store; + indexer?: Indexer; + untis?: UntisClient; + }, ) { this.config = config; this.client = shared?.client ?? new SchulcloudClient(config); this.files = shared?.files ?? new FileManager(this.client); this.store = shared?.store; this.indexer = shared?.indexer; + this.untis = shared?.untis ?? (config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined); } /** Cached `/me`. Shared promise, so concurrent first calls make one request. */ diff --git a/src/core/untis.ts b/src/core/untis.ts new file mode 100644 index 0000000..69b3c3b --- /dev/null +++ b/src/core/untis.ts @@ -0,0 +1,555 @@ +/** + * WebUntis: the timetable, its changes, homework and what was taught. + * + * The school keeps its timetable in WebUntis, not in Schulcloud — Schulcloud's + * course `times` are empty here — so "what do I have today, and has anything + * been cancelled" is a question only this API can answer. It is the other half + * of a school day: Schulcloud holds the material, WebUntis holds the schedule. + * + * **Read-only, but not by the Schulcloud client's rule.** This is JSON-RPC: + * every call is a POST, reads included, so "GET only" cannot be the guarantee. + * `READ_METHODS` is: `call` refuses any method outside it. That matters because + * the key is the mobile app's credential and can do what the app can — this + * account holds `W_OWN_ABSENCE`, so the same key could report the user absent. + * + * Authentication is a one-time code derived from the base32 key behind the QR + * code in WebUntis → Profil → Freigaben (see core/totp.ts). Each request signs + * itself, so unlike the Schulcloud session there is nothing to hold open and + * nothing to refresh — but the server's clock has to be right, which is what + * error -8524 means. + */ + +import { compactDate } from './dates.ts'; +import { totp } from './totp.ts'; + +export interface UntisConfig { + /** Bare host from the QR dialog's "Url" field, e.g. `ags-erfurt.webuntis.com`. */ + server: string; + /** The school's login name, e.g. `ags-erfurt`. */ + school: string; + user: string; + /** The base32 key from the QR dialog. A credential: never log it. */ + secret: string; +} + +/** + * The methods this client may call. Everything here reads; nothing writes. + * + * Verified against the live instance — `getClassregEvents2017` and + * `getSchoolyears2017` answer "Method not found" and are deliberately absent. + */ +const READ_METHODS = new Set([ + 'getUserData2017', + 'getTimetable2017', + 'getLessonTopic2017', + 'getHomeWork2017', + 'getMessagesOfDay2017', +]); + +/** + * The read-only guarantee for this API, at its single choke point. + * + * Exported so a test can hold it to it: this is the line that keeps a key which + * *can* write from being used to write. + */ +export function assertReadMethod(method: string): void { + if (!READ_METHODS.has(method)) { + throw new Error(`Refusing to call WebUntis method ${method}: not in the read-only allowlist.`); + } +} + +/** + * The client version the mobile API expects as `?v=`. + * + * Not optional: `jsonrpc_intern.do` reads the parameter without checking it, + * so omitting it fails with a Java NullPointerException reported as -8998. + */ +const API_VERSION = 'i3.2'; + +/** How long master data (subjects, teachers, rooms, holidays) is reused. */ +const MASTER_DATA_TTL_MS = 6 * 60 * 60_000; + +/** A JSON-RPC error from WebUntis. They arrive with HTTP 200 and an `error` body. */ +export class UntisApiError extends Error { + readonly code: number; + readonly method: string; + + constructor(code: number, method: string, message: string) { + super(`WebUntis ${method} failed (${code}): ${message}`); + this.name = 'UntisApiError'; + this.code = code; + this.method = method; + } + + /** -8504: the key or user name is wrong, or the key has been regenerated. */ + get isAuthFailure(): boolean { + return this.code === -8504; + } + + /** -8524: our clock is too far from the school server's. */ + get isClockSkew(): boolean { + return this.code === -8524; + } +} + +// --- what callers get ---------------------------------------------------- + +/** A subject, teacher, room or class: a short code plus, where known, a full name. */ +export interface UntisName { + name: string; + longName?: string; +} + +export interface UntisHomework { + id: number; + lessonId: number; + /** When it was set, `YYYY-MM-DD`. */ + assigned: string; + /** When it is due, `YYYY-MM-DD`. */ + due: string; + text: string; + remark?: string; + completed: boolean; + subject?: UntisName; + attachments: number; +} + +export interface UntisLesson { + /** The period id, which `getLessonTopic2017` takes. */ + periodId: number; + /** The lesson (series) id: the same weekly slot shares it. */ + lessonId: number; + date: string; + /** `HH:MM` in the school's local time. */ + start: string; + end: string; + /** Raw status words, e.g. `REGULAR`, `CANCELLED`, `IRREGULAR`. */ + statuses: string[]; + cancelled: boolean; + /** A substitution, a moved lesson or anything else Untis calls irregular. */ + changed: boolean; + subjects: UntisName[]; + teachers: UntisName[]; + rooms: UntisName[]; + classes: UntisName[]; + /** What each kind of element replaced, when Untis says so (its `orgId`). */ + replaced: { subjects: UntisName[]; teachers: UntisName[]; rooms: UntisName[] }; + /** The three free-text fields a teacher can attach to a period. */ + notes: { lesson?: string; substitution?: string; info?: string }; + homework: UntisHomework[]; + /** The exam module's title, when the school uses it. */ + exam?: string; + online: boolean; +} + +export interface UntisHoliday { + name: string; + longName: string; + start: string; + end: string; +} + +export interface UntisDay { + date: string; + lessons: UntisLesson[]; + /** Holidays and single free days covering this date. */ + holidays: UntisHoliday[]; +} + +export interface UntisTimetable { + from: string; + to: string; + /** Every date in the range, including the ones without lessons. */ + days: UntisDay[]; +} + +export interface UntisIdentity { + displayName: string; + elementId: number; + elementType: string; + schoolName: string; + /** Untis' own permission words, e.g. `R_MY_ABSENCES`, `W_OWN_ABSENCE`. */ + rights: string[]; +} + +/** One class-register entry: what was taught in a lesson of this series. */ +export interface UntisTopic { + text: string; + periodId: number; + date: string; + start: string; + end: string; +} + +export interface UntisMessage { + subject: string; + text: string; +} + +// --- raw shapes ---------------------------------------------------------- + +interface RawElement { + type: string; + id: number; + orgId?: number; +} + +interface RawPeriod { + id: number; + lessonId: number; + startDateTime: string; + endDateTime: string; + text?: { lesson?: string; substitution?: string; info?: string; attachments?: unknown[] }; + elements?: RawElement[]; + is?: string[]; + homeWorks?: RawHomework[]; + exam?: { name?: string; text?: string } | null; + isOnlinePeriod?: boolean; +} + +interface RawHomework { + id: number; + lessonId: number; + startDate: string; + endDate: string; + text: string; + remark?: string | null; + completed?: boolean; + attachments?: unknown[]; +} + +interface RawNamed { + id: number; + name: string; + longName?: string; + firstName?: string; + lastName?: string; +} + +interface RawMasterData { + timeStamp?: number; + subjects?: RawNamed[]; + teachers?: RawNamed[]; + rooms?: RawNamed[]; + klassen?: RawNamed[]; + holidays?: { name: string; longName: string; startDate: string; endDate: string }[]; +} + +interface RawUserData { + userData?: { + displayName?: string; + elemId?: number; + elemType?: string; + schoolName?: string; + rights?: string[]; + }; + masterData?: RawMasterData; +} + +interface RawTimetable { + timetable?: { periods?: RawPeriod[] }; + masterData?: RawMasterData; +} + +// --- client -------------------------------------------------------------- + +export class UntisClient { + private readonly config: UntisConfig; + private readonly timeoutMs: number; + private identityPromise: Promise | undefined; + private masterData: { data: RawMasterData; at: number } | undefined; + + constructor(config: UntisConfig, timeoutMs = 30_000) { + this.config = config; + this.timeoutMs = timeoutMs; + } + + /** For status lines: where this client is pointed, never how it authenticates. */ + get origin(): string { + return `${this.config.server}/${this.config.school}`; + } + + private async call(method: string, params: Record): Promise { + // The allowlist is the read-only guarantee for this API; widening it is a + // deliberate act, not something a caller can do by passing a string. + assertReadMethod(method); + const url = + `https://${this.config.server}/WebUntis/jsonrpc_intern.do` + + `?m=${encodeURIComponent(method)}&school=${encodeURIComponent(this.config.school)}&v=${API_VERSION}`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + // Honest, and accepted: the endpoint does not check for the app's own + // user agent. + 'user-agent': 'schulcloud-mcp', + }, + body: JSON.stringify({ + id: 'schulcloud-mcp', + jsonrpc: '2.0', + method, + params: [ + { + ...params, + // The code is a string: one in ten starts with a zero, which a + // JSON number would drop. + auth: { user: this.config.user, otp: totp(this.config.secret), clientTime: Date.now() }, + }, + ], + }), + signal: AbortSignal.timeout(this.timeoutMs), + }); + + const body = (await response.json().catch(() => undefined)) as + | { result?: T; error?: { code?: number; message?: string } } + | undefined; + // Failures come back as HTTP 200 with an `error` member, so the body is + // the thing to check first. + if (body?.error) { + throw new UntisApiError(body.error.code ?? 0, method, body.error.message ?? 'no message'); + } + if (!response.ok) throw new UntisApiError(0, method, `HTTP ${response.status}`); + if (body?.result === undefined) throw new UntisApiError(0, method, 'response carried no result'); + return body.result; + } + + /** Who the key belongs to. Cached; a failure is not, so a fixed key recovers. */ + identity(): Promise { + this.identityPromise ??= this.call('getUserData2017', {}) + .then((raw) => { + if (raw.masterData) this.masterData = { data: raw.masterData, at: Date.now() }; + const user = raw.userData ?? {}; + if (user.elemId === undefined || !user.elemType) { + throw new UntisApiError(0, 'getUserData2017', 'response carried no user element'); + } + return { + displayName: user.displayName ?? '(unnamed)', + elementId: user.elemId, + elementType: user.elemType, + schoolName: user.schoolName ?? this.config.school, + rights: user.rights ?? [], + }; + }) + .catch((error: unknown) => { + this.identityPromise = undefined; + throw error; + }); + return this.identityPromise; + } + + /** + * The timetable for a date range, with every day in it — including the ones + * with no lessons, because "no school today" is an answer and an empty list + * is not. + */ + async timetable(from: string, to: string): Promise { + const me = await this.identity(); + const raw = await this.call('getTimetable2017', { + id: me.elementId, + type: me.elementType, + startDate: compactDate(from), + endDate: compactDate(to), + // Always ask for full master data rather than a delta against a cached + // timestamp: the delta's removal semantics are unverified, and the whole + // set is one payload of a few hundred kilobytes a handful of times a day. + masterDataTimestamp: 0, + timetableTimestamp: 0, + timetableTimestamps: [], + }); + if (raw.masterData?.subjects) this.masterData = { data: raw.masterData, at: Date.now() }; + const master = raw.masterData ?? (await this.master()); + + const lessons = (raw.timetable?.periods ?? []).map((period) => this.toLesson(period, master)); + const days: UntisDay[] = []; + for (let date = from; date <= to; date = nextDate(date)) { + days.push({ + date, + lessons: lessons.filter((lesson) => lesson.date === date).sort(byStart), + holidays: holidaysOn(master, date), + }); + } + return { from, to, days }; + } + + /** + * Homework set for a date range. + * + * The range filters by the homework's own dates, not by when it was set, so + * a window that ends today shows nothing that is due tomorrow. + */ + async homework(from: string, to: string): Promise { + const me = await this.identity(); + const raw = await this.call<{ + homeWorks?: RawHomework[]; + lessonsById?: Record; + }>('getHomeWork2017', { + id: me.elementId, + type: me.elementType, + startDate: compactDate(from), + endDate: compactDate(to), + }); + const master = await this.master(); + const subjects = index(master.subjects); + return (raw.homeWorks ?? []) + .map((item) => { + const subjectId = raw.lessonsById?.[String(item.lessonId)]?.subjectId; + return toHomework(item, subjectId === undefined ? undefined : named(subjects.get(subjectId))); + }) + .sort((a, b) => a.due.localeCompare(b.due)); + } + + /** + * What was taught in the previous lessons of a period's series — the class + * register's "Unterrichtsinhalt", newest first. + * + * The parameter is a single `periodId`; a list is rejected as "period 0 not + * found". + */ + async lessonTopics(periodId: number): Promise { + const raw = await this.call<{ + previousTopics?: { text?: string; periodId?: number; startDateTime?: string; endDateTime?: string }[]; + }>('getLessonTopic2017', { periodId }); + return (raw.previousTopics ?? []) + .filter((topic) => topic.text?.trim()) + .map((topic) => { + const start = splitLocal(topic.startDateTime ?? ''); + const end = splitLocal(topic.endDateTime ?? ''); + return { + text: topic.text!.trim(), + periodId: topic.periodId ?? periodId, + date: start.date, + start: start.time, + end: end.time, + }; + }); + } + + /** The school's "Nachrichten des Tages" for one date. Often empty. */ + async messagesOfDay(date: string): Promise { + const raw = await this.call<{ messages?: { subject?: string; text?: string }[] }>('getMessagesOfDay2017', { + date: compactDate(date), + }); + return (raw.messages ?? []).map((message) => ({ + subject: message.subject?.trim() ?? '', + text: message.text?.trim() ?? '', + })); + } + + /** Master data, refreshed at most every few hours: it changes with the school year. */ + private async master(): Promise { + if (this.masterData && Date.now() - this.masterData.at < MASTER_DATA_TTL_MS) return this.masterData.data; + const raw = await this.call('getUserData2017', {}); + const data = raw.masterData ?? {}; + this.masterData = { data, at: Date.now() }; + return data; + } + + private toLesson(period: RawPeriod, master: RawMasterData): UntisLesson { + const start = splitLocal(period.startDateTime); + const end = splitLocal(period.endDateTime); + const maps = { + SUBJECT: index(master.subjects), + TEACHER: index(master.teachers), + ROOM: index(master.rooms), + CLASS: index(master.klassen), + }; + const of = (type: keyof typeof maps): RawElement[] => (period.elements ?? []).filter((e) => e.type === type); + const resolve = (type: keyof typeof maps): UntisName[] => + of(type).map((element) => named(maps[type].get(element.id)) ?? { name: `${type.toLowerCase()} #${element.id}` }); + // Untis expresses "X instead of Y" by keeping the original in orgId. + const replacedBy = (type: keyof typeof maps): UntisName[] => + of(type) + .filter((element) => element.orgId !== undefined && element.orgId !== element.id) + .map((element) => named(maps[type].get(element.orgId!)) ?? { name: `${type.toLowerCase()} #${element.orgId}` }); + + const statuses = period.is ?? []; + const note = (value: string | undefined): string | undefined => value?.trim() || undefined; + return { + periodId: period.id, + lessonId: period.lessonId, + date: start.date, + start: start.time, + end: end.time, + statuses, + cancelled: statuses.includes('CANCELLED'), + changed: statuses.includes('IRREGULAR') || statuses.includes('SUBSTITUTION'), + subjects: resolve('SUBJECT'), + teachers: resolve('TEACHER'), + rooms: resolve('ROOM'), + classes: resolve('CLASS'), + replaced: { subjects: replacedBy('SUBJECT'), teachers: replacedBy('TEACHER'), rooms: replacedBy('ROOM') }, + notes: { + lesson: note(period.text?.lesson), + substitution: note(period.text?.substitution), + info: note(period.text?.info), + }, + homework: (period.homeWorks ?? []).map((item) => toHomework(item, undefined)), + exam: note(period.exam?.name) ?? note(period.exam?.text), + online: period.isOnlinePeriod === true, + }; + } +} + +// --- helpers ------------------------------------------------------------- + +/** + * Splits `2026-09-21T11:45Z` into date and time — **as local school time**. + * + * The `Z` is a lie: the school's time grid starts lessons at 08:00 and the API + * reports exactly `08:00Z` for them. Parsing these as UTC would shift every + * lesson by an hour or two, so the string is taken apart rather than given to + * `new Date`. + */ +export function splitLocal(value: string): { date: string; time: string } { + const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value); + if (!match) { + // Dropping an unparsable lesson would silently shorten a school day; a + // changed format has to be visible. + throw new Error(`WebUntis returned a timestamp in an unexpected format: ${value}`); + } + return { date: match[1]!, time: match[2]! }; +} + +function index(list: RawNamed[] | undefined): Map { + return new Map((list ?? []).map((entry) => [entry.id, entry])); +} + +function named(entry: RawNamed | undefined): UntisName | undefined { + if (!entry) return undefined; + const full = [entry.firstName, entry.lastName].filter(Boolean).join(' ').trim(); + const longName = entry.longName?.trim() || full || undefined; + return { name: entry.name, ...(longName ? { longName } : {}) }; +} + +function toHomework(item: RawHomework, subject: UntisName | undefined): UntisHomework { + return { + id: item.id, + lessonId: item.lessonId, + assigned: item.startDate, + due: item.endDate, + text: item.text?.trim() ?? '', + ...(item.remark?.trim() ? { remark: item.remark.trim() } : {}), + completed: item.completed === true, + ...(subject ? { subject } : {}), + attachments: item.attachments?.length ?? 0, + }; +} + +function holidaysOn(master: RawMasterData, date: string): UntisHoliday[] { + return (master.holidays ?? []) + .filter((holiday) => holiday.startDate <= date && date <= holiday.endDate) + .map((holiday) => ({ + name: holiday.name.trim(), + longName: holiday.longName.trim(), + start: holiday.startDate, + end: holiday.endDate, + })); +} + +function nextDate(date: string): string { + return new Date(Date.parse(`${date}T12:00:00Z`) + 86_400_000).toISOString().slice(0, 10); +} + +function byStart(a: UntisLesson, b: UntisLesson): number { + return a.start.localeCompare(b.start) || a.periodId - b.periodId; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8fe44a8..21f5c41 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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); diff --git a/src/mcp/tools/overview.ts b/src/mcp/tools/overview.ts index 483bc40..f9b24ec 100644 --- a/src/mcp/tools/overview.ts +++ b/src/mcp/tools/overview.ts @@ -21,6 +21,8 @@ export function registerOverviewTools(server: McpServer, context: ServerContext) annotations: READ_ONLY, }, async () => { + // Never fails, so the WebUntis half survives a dead Schulcloud session. + const untis = await untisLine(context); try { const me = await context.me(); return text( @@ -33,13 +35,19 @@ export function registerOverviewTools(server: McpServer, context: ServerContext) `- Instance: ${context.config.baseUrl}`, `- Permissions: ${me.permissions.length}`, tokenExpiryLine(context.config.jwt), + untis, ] .filter(Boolean) .join('\n'), ]), ); } catch (error) { - return toToolError(error, 'read the current user'); + const problem = toToolError(error, 'read the current user'); + // One identity tool now answers for two systems: an expired Schulcloud + // token must not hide a working WebUntis key, or "is the server + // reachable?" gets a misleadingly total no. + if (!untis) return problem; + return { ...problem, content: [...problem.content, { type: 'text' as const, text: untis }] }; } }, ); @@ -188,6 +196,26 @@ export function registerOverviewTools(server: McpServer, context: ServerContext) ); } +/** + * The WebUntis side of the account, when configured. + * + * Never throws: whoami is the connectivity check, so a rejected Untis key has + * to be reported *in* the answer rather than replace it — the Schulcloud half + * of the report is still true and still useful. + */ +async function untisLine(context: ServerContext): Promise { + if (!context.untis) return undefined; + try { + const me = await context.untis.identity(); + return ( + `- WebUntis: ${me.displayName} (${me.elementType.toLowerCase()}) at ${me.schoolName}` + + ` — timetable via untis_timetable` + ); + } catch (error) { + return `- WebUntis: **not reachable** — ${error instanceof Error ? error.message : String(error)}`; + } +} + /** * When the server's Schulcloud token runs out. Only a person can renew it, so * the week before is worth saying out loud wherever the account is shown. diff --git a/src/mcp/tools/untis.ts b/src/mcp/tools/untis.ts new file mode 100644 index 0000000..178d768 --- /dev/null +++ b/src/mcp/tools/untis.ts @@ -0,0 +1,335 @@ +/** + * The WebUntis tools: the school day itself. + * + * Registered only when the server is configured for WebUntis, so a deployment + * without a key does not offer the model tools that can only fail. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { ServerContext } from '../../context.ts'; +import { addDays, daysBetween, germanDay, germanWeekday, isCalendarDate, schoolToday } from '../../core/dates.ts'; +import { heading, joinSections } from '../../core/text.ts'; +import { + UntisApiError, + type UntisClient, + type UntisDay, + type UntisHomework, + type UntisLesson, +} from '../../core/untis.ts'; +import { failure, text, toToolError } from './result.ts'; + +const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }; + +/** How far past the requested range to look when it turns out to be empty. */ +const LOOKAHEAD_DAYS = 14; + +/** + * Longest range either tool will fetch. + * + * A school week is about 40 periods, so a term's worth of timetable is + * thousands of lines of Markdown — past the point where it helps anyone, and + * the answer to "what do I have" is never three months of it. + */ +const MAX_RANGE_DAYS = 92; + +const dateArgument = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.') + .describe('A date as YYYY-MM-DD.'); + +export function registerUntisTools(server: McpServer, context: ServerContext): void { + const untis = context.untis; + if (!untis) return; + + server.registerTool( + 'untis_timetable', + { + title: 'Timetable (WebUntis)', + description: + 'The lessons of a school day or a date range from WebUntis ("Stundenplan"), with cancellations ' + + '("Entfall"), substitutions ("Vertretung"), room changes, the notes teachers attach to a period — ' + + 'announced tests are usually in there — plus homework due and any exam. This is the tool for "what ' + + 'do I have today/tomorrow", "has anything been cancelled" and for finding which subjects to prepare. ' + + 'Omit both dates for today. The material for those lessons is in Schulcloud, not here: match the ' + + 'subject to a course with list_courses. Every lesson line carries its period id for untis_lesson_topics.', + inputSchema: { + from: dateArgument.optional().describe('First day. Omit for today.'), + to: dateArgument.optional().describe('Last day, inclusive. Omit for a single day.'), + changesOnly: z + .boolean() + .default(false) + .describe('Only lessons that are cancelled or changed, for "what is different this week".'), + }, + annotations: READ_ONLY, + }, + async ({ from, to, changesOnly }) => { + const start = from ?? schoolToday(); + const end = to ?? start; + const unreal = [...new Set([start, end])].filter((value) => !isCalendarDate(value)); + if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`); + if (end < start) return failure(`The range ends before it starts: ${start} to ${end}.`); + if (daysBetween(start, end) > MAX_RANGE_DAYS) { + return failure( + `That is ${daysBetween(start, end)} days. Ask for at most ${MAX_RANGE_DAYS} at a time — a term of ` + + 'timetable is thousands of lines.', + ); + } + try { + return text(await readTimetable(untis, { from: start, to: end, changesOnly })); + } catch (error) { + return untisError(error, `read the timetable for ${start}${start === end ? '' : ` to ${end}`}`); + } + }, + ); + + server.registerTool( + 'untis_homework', + { + title: 'Homework (WebUntis)', + description: + 'Homework recorded in WebUntis\' class register ("Hausaufgaben"), with the day it was set and the day ' + + 'it is due. Separate from Schulcloud tasks ("Aufgaben") — a teacher uses one or the other, so check ' + + 'both when the user asks what they have to do: list_tasks covers the Schulcloud side. Defaults to the ' + + 'next two weeks.', + inputSchema: { + from: dateArgument.optional().describe('Earliest date to include. Omit for today.'), + to: dateArgument.optional().describe('Latest date to include. Omit for two weeks ahead.'), + includeCompleted: z.boolean().default(false).describe('Also list homework already ticked off.'), + }, + annotations: READ_ONLY, + }, + async ({ from, to, includeCompleted }) => { + const start = from ?? schoolToday(); + const end = to ?? addDays(start, 14); + const unreal = [...new Set([start, end])].filter((value) => !isCalendarDate(value)); + if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`); + if (end < start) return failure(`The range ends before it starts: ${start} to ${end}.`); + if (daysBetween(start, end) > MAX_RANGE_DAYS) { + return failure( + `That is ${daysBetween(start, end)} days. Ask for at most ${MAX_RANGE_DAYS} at a time — a term of ` + + 'timetable is thousands of lines.', + ); + } + try { + const all = await untis.homework(start, end); + const items = includeCompleted ? all : all.filter((item) => !item.completed); + if (items.length === 0) { + return text( + `No homework in WebUntis between ${germanDay(start)} and ${germanDay(end)}` + + `${includeCompleted ? '' : ' that is still open'}. Schulcloud tasks are separate — try list_tasks.`, + ); + } + return text( + joinSections([ + heading(2, `Hausaufgaben ${germanDay(start)} – ${germanDay(end)} (${items.length})`), + items.map(formatHomework).join('\n'), + ]), + ); + } catch (error) { + return untisError(error, 'read homework from WebUntis'); + } + }, + ); + + server.registerTool( + 'untis_lesson_topics', + { + title: 'What was taught (WebUntis)', + description: + 'The class register\'s record of what previous lessons of one series actually covered ' + + '("Unterrichtsinhalt"), newest first. Use it to prepare for the next lesson of a subject: pass the ' + + 'period id of an upcoming lesson from untis_timetable and it answers "where did we get to". Says ' + + 'nothing about material or homework — that is Schulcloud and untis_homework.', + inputSchema: { + periodId: z + .number() + .int() + .describe('The period id of a lesson, as untis_timetable prints it in backticks.'), + limit: z.number().int().min(1).max(50).default(10).describe('How many previous lessons to list.'), + }, + annotations: READ_ONLY, + }, + async ({ periodId, limit }) => { + try { + const topics = await untis.lessonTopics(periodId); + if (topics.length === 0) { + return text( + `No lesson contents recorded for period ${periodId}. Either the class register is empty for this ` + + 'series or the teacher does not fill it in.', + ); + } + return text( + joinSections([ + heading(2, `Unterrichtsinhalte (${Math.min(limit, topics.length)} of ${topics.length})`), + topics + .slice(0, limit) + .map((topic) => `- ${germanDay(topic.date)} ${topic.start}–${topic.end}: ${topic.text}`) + .join('\n'), + ]), + ); + } catch (error) { + return untisError(error, `read what was taught before period ${periodId}`); + } + }, + ); +} + +/** + * The timetable for a range as Markdown: what `untis_timetable` returns, and + * what the Tagesvorbereitung prompt attaches, so an attached day reads exactly + * like a fetched one. + */ +export async function readTimetable( + untis: UntisClient, + options: { from: string; to: string; changesOnly?: boolean }, +): Promise { + const { from: start, to: end, changesOnly = false } = options; + // One request covers the range plus a lookahead, so an empty range can still + // answer "your next lessons are on …" without a second call. + const table = await untis.timetable(start, addDays(end, LOOKAHEAD_DAYS)); + const asked = table.days.filter((day) => day.date <= end); + // With changesOnly a week of untouched days is noise, so only the days that + // changed are listed at all. + const shown = changesOnly + ? asked + .map((day) => ({ ...day, lessons: day.lessons.filter((l) => l.cancelled || l.changed) })) + .filter((day) => day.lessons.length > 0) + : asked; + const total = shown.reduce((sum, day) => sum + day.lessons.length, 0); + + // The Nachrichten des Tages belong to a single date; skip the call for a + // range, and never let it cost the timetable. + const messages = start === end && !changesOnly ? await untis.messagesOfDay(start).catch(() => []) : []; + + const nextWithLessons = table.days.find((day) => day.date > end && day.lessons.length > 0); + return joinSections([ + heading(2, start === end ? `${germanWeekday(start)}, ${germanDay(start)}` : `${germanDay(start)} – ${germanDay(end)}`), + changesOnly && total > 0 ? '_Only cancellations and changes._' : undefined, + changesOnly && total === 0 + ? `_Nothing cancelled or changed${start === end ? '' : ' on any day of this range'}._` + : undefined, + ...shown.map((day) => formatDay(day, start !== end)), + // Only about lessons: "your next change is on Friday" would be an odd thing + // to say, and misleading when nothing has changed. + total === 0 && !changesOnly && nextWithLessons + ? `**Next lessons:** ${germanWeekday(nextWithLessons.date)}, ${germanDay(nextWithLessons.date)} ` + + `(${nextWithLessons.lessons.length} lesson(s)) — ask again with from="${nextWithLessons.date}".` + : undefined, + messages.length > 0 + ? joinSections([ + heading(3, 'Nachrichten des Tages'), + messages.map((message) => `- ${[message.subject, message.text].filter(Boolean).join(': ')}`).join('\n'), + ]) + : undefined, + ]); +} + +// --- formatting ---------------------------------------------------------- + +function formatDay(day: UntisDay, withHeading: boolean): string { + const holidays = day.holidays.map((holiday) => `_${holiday.longName || holiday.name}_`).join(', '); + // A day with no lessons and no holiday is normal at a vocational school — + // the weeks in the company have no timetable — so say that rather than + // leaving a bare "nothing", which reads like a failed lookup. + const body = + day.lessons.length > 0 + ? day.lessons.map((lesson) => formatLesson(lesson, day.lessons)).join('\n') + : holidays + ? '_No lessons._' + : '_No lessons. Not a holiday either — a company phase or a free day._'; + return joinSections([ + withHeading ? heading(3, `${germanWeekday(day.date)}, ${germanDay(day.date)}`) : undefined, + holidays || undefined, + body, + ]); +} + +/** + * One lesson as a line. + * + * A substitution arrives as two periods in the same slot — the original marked + * CANCELLED and the replacement marked IRREGULAR — rather than as one period + * with a changed teacher, so the replacement names what it stands in for by + * looking at what was cancelled beside it. + */ +function formatLesson(lesson: UntisLesson, sameDay: UntisLesson[]): string { + const names = (list: { name: string; longName?: string }[]): string => list.map((entry) => entry.name).join(', '); + const subject = lesson.subjects[0]; + const subjectLabel = subject + ? `**${subject.name}**${subject.longName ? ` (${subject.longName})` : ''}` + : '**(no subject)**'; + + const instead = [ + ...lesson.replaced.teachers.map((entry) => entry.name), + ...(lesson.changed ? replacedBeside(lesson, sameDay) : []), + ]; + const status = lesson.cancelled + ? ' — **Entfall**' + : lesson.changed + ? ` — **Vertretung**${instead.length > 0 ? ` (statt ${[...new Set(instead)].join(', ')})` : ''}` + : ''; + const room = lesson.rooms.length > 0 ? ` · Raum ${names(lesson.rooms)}` : ''; + const replacedRoom = lesson.replaced.rooms.length > 0 ? ` (statt ${names(lesson.replaced.rooms)})` : ''; + const teachers = lesson.teachers.length > 0 ? ` · ${lesson.teachers.map(withFullName).join(', ')}` : ''; + const unknownStatus = lesson.statuses.filter((value) => !['REGULAR', 'CANCELLED', 'IRREGULAR'].includes(value)); + + const notes = [ + lesson.exam ? `**Prüfung:** ${lesson.exam}` : undefined, + lesson.notes.info, + lesson.notes.substitution ? `Vertretungstext: ${lesson.notes.substitution}` : undefined, + lesson.notes.lesson, + ...lesson.homework.map((item) => `Hausaufgabe bis ${germanDay(item.due)}: ${item.text}`), + unknownStatus.length > 0 ? `Status: ${unknownStatus.join(', ')}` : undefined, + lesson.online ? 'Online' : undefined, + ].filter((note): note is string => Boolean(note)); + + const head = + `- ${lesson.start}–${lesson.end} ${subjectLabel}${room}${replacedRoom}${teachers}${status} ` + + `\`${lesson.periodId}\``; + return notes.length > 0 ? `${head}\n${notes.map((note) => ` - ${note}`).join('\n')}` : head; +} + +/** The teachers of a cancelled lesson in the same slot: who this one stands in for. */ +function replacedBeside(lesson: UntisLesson, sameDay: UntisLesson[]): string[] { + return sameDay + .filter((other) => other.cancelled && other.start === lesson.start && other.periodId !== lesson.periodId) + .flatMap((other) => other.teachers.map((teacher) => teacher.name)); +} + +function withFullName(entry: { name: string; longName?: string }): string { + return entry.longName ? `${entry.name} (${entry.longName})` : entry.name; +} + +function formatHomework(item: UntisHomework): string { + const subject = item.subject ? ` — ${item.subject.name}${item.subject.longName ? ` (${item.subject.longName})` : ''}` : ''; + const done = item.completed ? ' [erledigt]' : ''; + const remark = item.remark ? ` — ${item.remark}` : ''; + const attachments = item.attachments > 0 ? ` (${item.attachments} attachment(s))` : ''; + return `- **bis ${germanDay(item.due)}**${subject}: ${item.text}${remark}${attachments}${done} (set ${germanDay(item.assigned)})`; +} + +/** + * WebUntis failures the user has to act on, separated from the rest. + * + * A rejected key and a wrong clock are the two that no retry fixes, and they + * are indistinguishable from "no lessons" unless they are named. + */ +function untisError(error: unknown, action: string) { + if (error instanceof UntisApiError) { + if (error.isAuthFailure) { + return failure( + `WebUntis rejected the server's key while trying to ${action}.\n\n` + + 'The key from WebUntis → Profil → Freigaben → Untis Mobile has been regenerated or revoked. ' + + 'Put the current one in UNTIS_SECRET and restart the server.', + ); + } + if (error.isClockSkew) { + return failure( + `WebUntis refused the one-time code while trying to ${action}: the server's clock is too far off.\n\n` + + 'The code is time-based, so the host needs a working NTP sync.', + ); + } + } + return toToolError(error, action); +} diff --git a/src/services.ts b/src/services.ts index 7183c63..2d9b86e 100644 --- a/src/services.ts +++ b/src/services.ts @@ -3,6 +3,7 @@ import { SchulcloudClient } from './core/client.ts'; import type { SessionKeepalive } from './core/keepalive.ts'; import { FileManager } from './core/legacy-files.ts'; import { SessionToken } from './core/session-token.ts'; +import { UntisClient } from './core/untis.ts'; import { Indexer } from './indexer/indexer.ts'; import { Store } from './store/store.ts'; @@ -27,6 +28,12 @@ export interface Services { indexer: Indexer | undefined; /** The Schulcloud token, which `/api/token` can replace without a restart. */ session: SessionToken; + /** + * WebUntis, when configured. Process-wide so its master data — 140 subjects, + * 216 teachers, every holiday of the school year — is fetched once rather + * than per MCP session. + */ + untis: UntisClient | undefined; /** * Set by the entry point that runs one, for status reports. Created there * rather than here because each entry point logs to a different stream. @@ -44,6 +51,7 @@ export async function createServices(config: Config): Promise { const files = new FileManager(client); const store = await Store.open(config.databaseUrl); const indexer = store ? new Indexer(client, store, config) : undefined; + const untis = config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined; if (!store) { console.warn( @@ -51,7 +59,7 @@ export async function createServices(config: Config): Promise { '/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.', ); } - return { config, client, files, store, indexer, session }; + return { config, client, files, store, indexer, session, untis }; } export async function closeServices(services: Services): Promise { diff --git a/test/config.test.ts b/test/config.test.ts index 2c2a688..b91c540 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -99,3 +99,55 @@ describe('loadConfig: connector token', () => { assert.throws(() => loadConfig(), /must differ from MCP_AUTH_TOKEN/); }); }); + +describe('loadConfig: WebUntis', () => { + const UNTIS = { + UNTIS_SERVER: 'ags-erfurt.webuntis.com', + UNTIS_SCHOOL: 'ags-erfurt', + UNTIS_USER: 'fia24b-test', + UNTIS_SECRET: 'JBSWY3DPEHPK3PXP', + }; + + /** The four are read from the environment, so a stray real value must not leak in. */ + function only(overrides: Partial = {}): void { + process.env.TSC_URL = 'https://example.org'; + process.env.TSC_JWT_COOKIE = 'x'; + for (const name of Object.keys(UNTIS)) delete process.env[name]; + for (const [name, value] of Object.entries(overrides)) process.env[name] = value; + } + + it('is off when nothing is set', () => { + only(); + assert.equal(loadConfig().untis, undefined); + }); + + it('reads all four values', () => { + only(UNTIS); + assert.deepEqual(loadConfig().untis, { + server: 'ags-erfurt.webuntis.com', + school: 'ags-erfurt', + user: 'fia24b-test', + secret: 'JBSWY3DPEHPK3PXP', + }); + }); + + it('names what is missing instead of half-configuring itself', () => { + only({ UNTIS_SERVER: UNTIS.UNTIS_SERVER, UNTIS_SCHOOL: UNTIS.UNTIS_SCHOOL }); + assert.throws(() => loadConfig(), /missing: UNTIS_USER, UNTIS_SECRET/); + assert.throws(() => loadConfig(), /Profil/); + }); + + it('refuses a URL where the bare host belongs', () => { + only({ ...UNTIS, UNTIS_SERVER: 'https://ags-erfurt.webuntis.com/WebUntis/' }); + assert.throws(() => loadConfig(), /UNTIS_SERVER must be the bare host/); + }); + + it('refuses a key that is not base32, without echoing it', () => { + const secret = 'not-a-base32-key!'; + only({ ...UNTIS, UNTIS_SECRET: secret }); + assert.throws( + () => loadConfig(), + (error: Error) => /UNTIS_SECRET/.test(error.message) && !error.message.includes(secret), + ); + }); +}); diff --git a/test/untis.test.ts b/test/untis.test.ts new file mode 100644 index 0000000..e7ce023 --- /dev/null +++ b/test/untis.test.ts @@ -0,0 +1,406 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; +import { + assertReadMethod, + splitLocal, + UntisApiError, + UntisClient, + type UntisClient as UntisClientType, + type UntisTimetable, +} from '../src/core/untis.ts'; +import { readTimetable } from '../src/mcp/tools/untis.ts'; + +const CONFIG = { server: 'school.webuntis.com', school: 'demo', user: 'fia24b-test', secret: 'JBSWY3DPEHPK3PXP' }; + +/** Master data in the shape the live instance returns it. */ +const MASTER = { + timeStamp: 1, + subjects: [ + { id: 35, name: 'IT-FIA', longName: 'Fachtheorie FIA' }, + { id: 130, name: 'Eth', longName: 'Ethik' }, + ], + teachers: [ + { id: 1, name: 'Ra', firstName: 'Kristin', lastName: 'Rammelt' }, + { id: 2, name: 'Hy', firstName: 'Justus', lastName: 'Hoyme' }, + ], + rooms: [ + { id: 325, name: '42', longName: 'Raum 42' }, + { id: 1, name: '001', longName: 'GR - Praxis' }, + ], + klassen: [{ id: 2757, name: 'FIA24B', longName: 'Fachinformatiker' }], + holidays: [ + { + name: 'Weltkindertag', + longName: 'Weltkindertag (20.09.2026)', + startDate: '2026-09-20', + endDate: '2026-09-20', + }, + ], +}; + +const USER_DATA = { + userData: { + displayName: 'Hamm Fabian', + elemId: 19_031, + elemType: 'STUDENT', + schoolName: 'Andreas-Gordon-Schule', + rights: ['CLASSREGISTER', 'W_OWN_ABSENCE'], + }, + masterData: MASTER, +}; + +/** A regular lesson, a cancelled one and its replacement in the same slot. */ +const PERIODS = [ + { + id: 100, + lessonId: 900, + startDateTime: '2026-09-21T08:00Z', + endDateTime: '2026-09-21T08:45Z', + text: { lesson: '', substitution: '', info: 'Test Projektdoku', attachments: [] }, + elements: [ + { type: 'CLASS', id: 2757, orgId: 2757 }, + { type: 'TEACHER', id: 1, orgId: 1 }, + { type: 'SUBJECT', id: 35, orgId: 35 }, + { type: 'ROOM', id: 325, orgId: 325 }, + ], + is: ['REGULAR'], + homeWorks: [ + { id: 7, lessonId: 900, startDate: '2026-09-14', endDate: '2026-09-21', text: 'Plakat mitbringen', completed: false }, + ], + exam: null, + isOnlinePeriod: false, + }, + { + id: 101, + lessonId: 901, + startDateTime: '2026-09-21T10:05Z', + endDateTime: '2026-09-21T10:50Z', + elements: [ + { type: 'TEACHER', id: 1, orgId: 1 }, + { type: 'SUBJECT', id: 35, orgId: 35 }, + ], + is: ['CANCELLED'], + }, + { + id: 102, + lessonId: 902, + startDateTime: '2026-09-21T10:05Z', + endDateTime: '2026-09-21T10:50Z', + text: { substitution: 'Unterricht vom 30.09.2026' }, + elements: [ + { type: 'TEACHER', id: 2, orgId: 2 }, + { type: 'SUBJECT', id: 130, orgId: 130 }, + { type: 'ROOM', id: 325, orgId: 1 }, + ], + is: ['IRREGULAR'], + }, +]; + +const savedFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = savedFetch; +}); + +/** Captures every request and answers per method. */ +function stub(answers: Record): { calls: { method: string; url: string; body: any }[] } { + const calls: { method: string; url: string; body: any }[] = []; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + calls.push({ method: body.method, url: String(url), body }); + const answer = answers[body.method]; + if (answer === undefined) return Response.json({ error: { code: -32_601, message: 'Method not found' } }); + if (answer instanceof Error) return Response.json({ error: { code: Number(answer.message), message: 'nope' } }); + return Response.json({ result: answer }); + }) as typeof fetch; + return { calls }; +} + +describe('UntisClient: request shape', () => { + it('signs each request with a padded string code and the school in the query', async () => { + const { calls } = stub({ getUserData2017: USER_DATA }); + await new UntisClient(CONFIG).identity(); + + const [call] = calls; + assert.ok(call); + assert.match(call.url, /^https:\/\/school\.webuntis\.com\/WebUntis\/jsonrpc_intern\.do\?/); + assert.match(call.url, /m=getUserData2017/); + assert.match(call.url, /school=demo/); + // Without a version parameter the endpoint answers with a Java NPE. + assert.match(call.url, /v=i3\.2/); + const auth = call.body.params[0].auth; + assert.equal(auth.user, 'fia24b-test'); + assert.equal(typeof auth.otp, 'string'); + assert.match(auth.otp, /^\d{6}$/); + assert.ok(Math.abs(auth.clientTime - Date.now()) < 5_000); + // The key itself never travels. + assert.ok(!JSON.stringify(call.body).includes(CONFIG.secret)); + }); + + it('refuses any method outside the read-only allowlist', () => { + assert.throws(() => assertReadMethod('submitAbsencesChecked2017'), /read-only allowlist/); + assert.throws(() => assertReadMethod('createImmediateAbsence2017'), /read-only allowlist/); + assert.doesNotThrow(() => assertReadMethod('getTimetable2017')); + }); + + it('names the two failures a person has to act on', async () => { + for (const [code, flag] of [ + [-8504, 'isAuthFailure'], + [-8524, 'isClockSkew'], + ] as const) { + stub({ getUserData2017: new Error(String(code)) }); + const error = await new UntisClient(CONFIG).identity().then( + () => undefined, + (e: unknown) => e, + ); + assert.ok(error instanceof UntisApiError, `code ${code}`); + assert.equal(error.code, code); + assert.equal(error[flag], true); + } + }); + + it('caches the identity but not a failure', async () => { + const failing = stub({ getUserData2017: new Error('-8504') }); + const client = new UntisClient(CONFIG); + await client.identity().catch(() => undefined); + await client.identity().catch(() => undefined); + assert.equal(failing.calls.length, 2, 'a rejected key must be retried after it is fixed'); + + const working = stub({ getUserData2017: USER_DATA }); + const cached = new UntisClient(CONFIG); + const first = await cached.identity(); + const second = await cached.identity(); + assert.equal(working.calls.length, 1); + assert.equal(first.displayName, 'Hamm Fabian'); + assert.deepEqual(second.rights, ['CLASSREGISTER', 'W_OWN_ABSENCE']); + }); +}); + +describe('UntisClient: timetable', () => { + const load = async (): Promise => { + stub({ getUserData2017: USER_DATA, getTimetable2017: { timetable: { periods: PERIODS }, masterData: MASTER } }); + return new UntisClient(CONFIG).timetable('2026-09-19', '2026-09-21'); + }; + + it('keeps the reported clock time, which is local despite the Z', async () => { + const table = await load(); + const monday = table.days.find((day) => day.date === '2026-09-21'); + assert.ok(monday); + // 08:00Z must stay 08:00: the school's first lesson starts at eight. + assert.equal(monday.lessons[0]?.start, '08:00'); + assert.equal(monday.lessons[0]?.end, '08:45'); + }); + + it('includes days without lessons, with the holiday that explains them', async () => { + const table = await load(); + assert.deepEqual( + table.days.map((day) => day.date), + ['2026-09-19', '2026-09-20', '2026-09-21'], + ); + const sunday = table.days.find((day) => day.date === '2026-09-20'); + assert.equal(sunday?.lessons.length, 0); + assert.equal(sunday?.holidays[0]?.name, 'Weltkindertag'); + assert.equal(table.days.find((day) => day.date === '2026-09-19')?.holidays.length, 0); + }); + + it('resolves subjects, teachers, rooms and classes to their names', async () => { + const lesson = (await load()).days.at(-1)?.lessons[0]; + assert.deepEqual(lesson?.subjects, [{ name: 'IT-FIA', longName: 'Fachtheorie FIA' }]); + assert.deepEqual(lesson?.teachers, [{ name: 'Ra', longName: 'Kristin Rammelt' }]); + assert.deepEqual(lesson?.rooms, [{ name: '42', longName: 'Raum 42' }]); + assert.deepEqual(lesson?.classes, [{ name: 'FIA24B', longName: 'Fachinformatiker' }]); + assert.equal(lesson?.notes.info, 'Test Projektdoku'); + assert.equal(lesson?.homework[0]?.due, '2026-09-21'); + assert.equal(lesson?.periodId, 100); + }); + + it('marks a cancellation and its replacement, including what was replaced', async () => { + const lessons = (await load()).days.at(-1)!.lessons; + const cancelled = lessons.find((lesson) => lesson.periodId === 101); + const replacement = lessons.find((lesson) => lesson.periodId === 102); + assert.equal(cancelled?.cancelled, true); + assert.equal(cancelled?.changed, false); + assert.equal(replacement?.changed, true); + assert.equal(replacement?.cancelled, false); + assert.equal(replacement?.notes.substitution, 'Unterricht vom 30.09.2026'); + // The room carries an orgId of its own: the lesson is in 42 instead of 001, + // and `replaced` is the room it moved out of. + assert.deepEqual(replacement?.rooms, [{ name: '42', longName: 'Raum 42' }]); + assert.deepEqual(replacement?.replaced.rooms, [{ name: '001', longName: 'GR - Praxis' }]); + }); + + it('sorts a day by clock time', async () => { + const starts = (await load()).days.at(-1)!.lessons.map((lesson) => lesson.start); + assert.deepEqual(starts, ['08:00', '10:05', '10:05']); + }); + + it('throws on a timestamp it cannot read rather than dropping the lesson', () => { + assert.throws(() => splitLocal('21.09.2026 08:00'), /unexpected format/); + assert.deepEqual(splitLocal('2026-09-21T08:00Z'), { date: '2026-09-21', time: '08:00' }); + }); +}); + +describe('UntisClient: homework and lesson topics', () => { + it('resolves a homework subject through its lesson', async () => { + stub({ + getUserData2017: USER_DATA, + getHomeWork2017: { + homeWorks: [ + { + id: 16_404, + lessonId: 71_296, + startDate: '2026-08-24', + endDate: '2026-08-31', + text: 'Material Plakat Kohlbergs Stufenmodell', + remark: null, + completed: false, + attachments: [], + }, + ], + lessonsById: { '71296': { id: 71_296, subjectId: 130 } }, + }, + }); + const [item] = await new UntisClient(CONFIG).homework('2026-08-01', '2026-09-30'); + assert.equal(item?.subject?.name, 'Eth'); + assert.equal(item?.due, '2026-08-31'); + assert.equal(item?.assigned, '2026-08-24'); + assert.equal(item?.completed, false); + }); + + it('reads what previous lessons covered, dropping the empty entries', async () => { + stub({ + getUserData2017: USER_DATA, + getLessonTopic2017: { + previousTopics: [ + { text: 'Projektplanung und Risikoanalyse', periodId: 5, startDateTime: '2026-09-02T11:45Z', endDateTime: '2026-09-02T12:30Z' }, + { text: ' ', periodId: 6, startDateTime: '2026-09-01T11:45Z', endDateTime: '2026-09-01T12:30Z' }, + ], + }, + }); + const topics = await new UntisClient(CONFIG).lessonTopics(100); + assert.equal(topics.length, 1); + assert.deepEqual(topics[0], { + text: 'Projektplanung und Risikoanalyse', + periodId: 5, + date: '2026-09-02', + start: '11:45', + end: '12:30', + }); + }); +}); + +describe('readTimetable', () => { + /** Only the two methods the renderer uses, so the formatting is what is tested. */ + const fake = (days: UntisTimetable['days'], messages: { subject: string; text: string }[] = []) => + ({ + timetable: async (from: string, to: string) => ({ + from, + to, + days: days.filter((day) => day.date >= from && day.date <= to), + }), + messagesOfDay: async () => messages, + }) as unknown as UntisClientType; + + const lesson = (over: Partial = {}) => ({ + periodId: 100, + lessonId: 900, + date: '2026-09-21', + start: '08:00', + end: '08:45', + statuses: ['REGULAR'], + cancelled: false, + changed: false, + subjects: [{ name: 'IT-FIA', longName: 'Fachtheorie FIA' }], + teachers: [{ name: 'Ra', longName: 'Kristin Rammelt' }], + rooms: [{ name: '42' }], + classes: [], + replaced: { subjects: [], teachers: [], rooms: [] }, + notes: {}, + homework: [], + online: false, + ...over, + }); + + it('renders a day with its weekday, subject, room, teacher and period id', async () => { + const out = await readTimetable(fake([{ date: '2026-09-21', lessons: [lesson()], holidays: [] }]), { + from: '2026-09-21', + to: '2026-09-21', + }); + assert.match(out, /## Montag, 21\.09\.2026/); + assert.match(out, /08:00–08:45 \*\*IT-FIA\*\* \(Fachtheorie FIA\) · Raum 42 · Ra \(Kristin Rammelt\)/); + assert.match(out, /`100`/); + }); + + it('says Entfall, and names who a Vertretung stands in for', async () => { + const lessons = [ + lesson({ periodId: 101, cancelled: true, statuses: ['CANCELLED'] }), + lesson({ + periodId: 102, + changed: true, + statuses: ['IRREGULAR'], + teachers: [{ name: 'Hy', longName: 'Justus Hoyme' }], + notes: { substitution: 'Unterricht vom 30.09.2026' }, + }), + ]; + const out = await readTimetable(fake([{ date: '2026-09-21', lessons, holidays: [] }]), { + from: '2026-09-21', + to: '2026-09-21', + }); + assert.match(out, /\*\*Entfall\*\*/); + assert.match(out, /\*\*Vertretung\*\* \(statt Ra\)/); + assert.match(out, /Vertretungstext: Unterricht vom 30\.09\.2026/); + }); + + it('explains an empty day and points at the next one with lessons', async () => { + const out = await readTimetable( + fake([ + { date: '2026-09-17', lessons: [], holidays: [] }, + { date: '2026-09-21', lessons: [lesson()], holidays: [] }, + ]), + { from: '2026-09-17', to: '2026-09-17' }, + ); + assert.match(out, /No lessons\. Not a holiday either/); + assert.match(out, /\*\*Next lessons:\*\* Montag, 21\.09\.2026 \(1 lesson\(s\)\)/); + }); + + it('names the holiday when there is one, instead of guessing', async () => { + const out = await readTimetable( + fake([ + { + date: '2026-10-13', + lessons: [], + holidays: [{ name: 'Herbstferien', longName: 'Herbstferien (12.10.-23.10.)', start: '2026-10-12', end: '2026-10-23' }], + }, + ]), + { from: '2026-10-13', to: '2026-10-13' }, + ); + assert.match(out, /Herbstferien \(12\.10\.-23\.10\.\)/); + assert.match(out, /_No lessons\._/); + assert.doesNotMatch(out, /company phase/); + }); + + it('with changesOnly lists only changed days, and says so when none changed', async () => { + const days = [ + { date: '2026-09-21', lessons: [lesson()], holidays: [] }, + { date: '2026-09-22', lessons: [lesson({ date: '2026-09-22', periodId: 103, cancelled: true })], holidays: [] }, + ]; + const out = await readTimetable(fake(days), { from: '2026-09-21', to: '2026-09-22', changesOnly: true }); + assert.match(out, /Dienstag, 22\.09\.2026/); + assert.doesNotMatch(out, /Montag, 21\.09\.2026/); + + const quiet = await readTimetable(fake([days[0]!]), { from: '2026-09-21', to: '2026-09-21', changesOnly: true }); + assert.match(quiet, /Nothing cancelled or changed/); + // The next-lessons hint is about lessons, so it has no place here. + assert.doesNotMatch(quiet, /Next lessons/); + }); + + it('appends the Nachrichten des Tages for a single day only', async () => { + const day = [{ date: '2026-09-21', lessons: [lesson()], holidays: [] }]; + const messages = [{ subject: 'Hitzefrei', text: 'Unterrichtsschluss 12:30' }]; + const single = await readTimetable(fake(day, messages), { from: '2026-09-21', to: '2026-09-21' }); + assert.match(single, /Nachrichten des Tages/); + assert.match(single, /Hitzefrei: Unterrichtsschluss 12:30/); + + const range = await readTimetable(fake(day, messages), { from: '2026-09-21', to: '2026-09-25' }); + assert.doesNotMatch(range, /Nachrichten des Tages/); + }); +});